diff --git a/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts b/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts
index ebddfdbe95..667d5abc38 100644
--- a/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts
+++ b/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts
@@ -28,8 +28,23 @@ export class RendererHedgehogModeHost implements HedgehogModeHost {
await game.render(container);
+ const canvas = game.app.canvas;
+ const notifyContextLost = () => options.onContextLost?.();
+ canvas.addEventListener("webglcontextlost", notifyContextLost, {
+ once: true,
+ });
+
return {
- destroy: () => game.destroy(),
+ destroy: () => {
+ canvas.removeEventListener("webglcontextlost", notifyContextLost);
+ game.destroy();
+ },
+ isContextLost: () => {
+ const renderer = game.app.renderer as unknown as {
+ context?: { isLost?: boolean };
+ };
+ return renderer.context?.isLost === true;
+ },
};
}
}
diff --git a/packages/ui/src/shell/HedgehogMode.test.tsx b/packages/ui/src/shell/HedgehogMode.test.tsx
new file mode 100644
index 0000000000..1bcfada5f6
--- /dev/null
+++ b/packages/ui/src/shell/HedgehogMode.test.tsx
@@ -0,0 +1,199 @@
+import { act, cleanup, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ mount: vi.fn(),
+ destroy: vi.fn(),
+ captureException: vi.fn(),
+ contextLost: false,
+ onContextLost: undefined as (() => void) | undefined,
+}));
+
+const settingsState = vi.hoisted(() => ({
+ hedgehogMode: true,
+ setHedgehogMode: () => {},
+}));
+
+vi.mock("@posthog/di/react", () => ({
+ useService: () => ({ mount: mocks.mount }),
+}));
+
+vi.mock("../features/auth/useMeQuery", () => ({
+ useMeQuery: () => ({ data: undefined }),
+}));
+
+vi.mock("../features/settings/settingsStore", () => ({
+ useSettingsStore: (selector: (state: unknown) => unknown) =>
+ selector(settingsState),
+}));
+
+vi.mock("./analytics", () => ({
+ captureException: mocks.captureException,
+}));
+
+vi.mock("./logger", () => ({
+ logger: {
+ scope: () => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ }),
+ },
+}));
+
+import { HedgehogMode } from "./HedgehogMode";
+import { useRendererWindowFocusStore } from "./rendererWindowFocusStore";
+
+function mountGameInto(
+ container: HTMLDivElement,
+ options: { onContextLost?: () => void },
+) {
+ const canvas = document.createElement("canvas");
+ container.appendChild(canvas);
+ mocks.onContextLost = options.onContextLost;
+ mocks.destroy.mockImplementation(() => canvas.remove());
+ return Promise.resolve({
+ destroy: mocks.destroy,
+ isContextLost: () => mocks.contextLost,
+ });
+}
+
+async function loseContext() {
+ mocks.contextLost = true;
+ await act(async () => {
+ mocks.onContextLost?.();
+ });
+}
+
+async function renderHedgehogMode() {
+ const view = render();
+ await act(async () => {});
+ return { view, overlay: view.container.firstElementChild as HTMLDivElement };
+}
+
+async function remountAfterDelay() {
+ mocks.contextLost = false;
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ mocks.mount.mockImplementation(mountGameInto);
+ mocks.contextLost = false;
+ settingsState.hedgehogMode = true;
+});
+
+afterEach(() => {
+ cleanup();
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+});
+
+describe("HedgehogMode", () => {
+ it("mounts the game into the overlay container", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ expect(mocks.mount).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).not.toBeNull();
+ expect(overlay.style.visibility).toBe("visible");
+ });
+
+ it("destroys the game and reports when the context loss callback fires", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ await loseContext();
+
+ expect(mocks.destroy).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ expect(mocks.captureException).toHaveBeenCalledWith(
+ expect.any(Error),
+ expect.objectContaining({ source: "hedgehog-mode", losses: 1 }),
+ );
+ });
+
+ it("tears down when polling detects a lost context without a callback", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ mocks.contextLost = true;
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+
+ expect(mocks.destroy).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ expect(mocks.captureException).toHaveBeenCalledTimes(1);
+ });
+
+ it("tears down when a lost context is detected on window focus", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ mocks.contextLost = true;
+ await act(async () => {
+ useRendererWindowFocusStore.setState({ focused: true });
+ });
+
+ expect(mocks.destroy).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ });
+
+ it("remounts the game after the context loss delay", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ await loseContext();
+ await remountAfterDelay();
+
+ expect(mocks.mount).toHaveBeenCalledTimes(2);
+ expect(overlay.querySelector("canvas")).not.toBeNull();
+ expect(overlay.style.visibility).toBe("visible");
+ });
+
+ it("hides the overlay after repeated context losses", async () => {
+ const { overlay } = await renderHedgehogMode();
+
+ for (let loss = 0; loss < 4; loss += 1) {
+ await loseContext();
+ await remountAfterDelay();
+ }
+
+ expect(mocks.mount).toHaveBeenCalledTimes(4);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ expect(overlay.style.visibility).toBe("hidden");
+
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(mocks.mount).toHaveBeenCalledTimes(4);
+ });
+
+ it("destroys the game on toggle off and remounts armed on re-enable", async () => {
+ const { view, overlay } = await renderHedgehogMode();
+ expect(mocks.mount).toHaveBeenCalledTimes(1);
+
+ settingsState.hedgehogMode = false;
+ view.rerender();
+ expect(mocks.destroy).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ expect(overlay.style.visibility).toBe("hidden");
+
+ settingsState.hedgehogMode = true;
+ view.rerender();
+ await act(async () => {});
+ expect(mocks.mount).toHaveBeenCalledTimes(2);
+
+ await loseContext();
+ expect(mocks.captureException).toHaveBeenCalledTimes(1);
+ expect(overlay.querySelector("canvas")).toBeNull();
+ });
+
+ it("destroys the game on unmount", async () => {
+ const { view } = await renderHedgehogMode();
+
+ view.unmount();
+
+ expect(mocks.destroy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/shell/HedgehogMode.tsx b/packages/ui/src/shell/HedgehogMode.tsx
index a76aa2712e..179825964c 100644
--- a/packages/ui/src/shell/HedgehogMode.tsx
+++ b/packages/ui/src/shell/HedgehogMode.tsx
@@ -1,15 +1,20 @@
import { useService } from "@posthog/di/react";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { useMeQuery } from "../features/auth/useMeQuery";
import { useSettingsStore } from "../features/settings/settingsStore";
+import { captureException } from "./analytics";
import {
HEDGEHOG_MODE_HOST,
type HedgehogModeHandle,
type HedgehogModeHost,
} from "./hedgehogModeHost";
import { logger } from "./logger";
+import { useRendererWindowFocusStore } from "./rendererWindowFocusStore";
const log = logger.scope("hedgehog-mode");
+const MAX_CONTEXT_LOSS_REMOUNTS = 3;
+const REMOUNT_DELAY_MS = 2000;
+const CONTEXT_CHECK_INTERVAL_MS = 10_000;
export function HedgehogMode() {
const hedgehogMode = useSettingsStore((s) => s.hedgehogMode);
@@ -18,12 +23,19 @@ export function HedgehogMode() {
const host = useService(HEDGEHOG_MODE_HOST);
const containerRef = useRef(null);
const handleRef = useRef(null);
+ const [gameDead, setGameDead] = useState(false);
useEffect(() => {
- if (!hedgehogMode || !containerRef.current || handleRef.current) return;
- if (!host) return;
+ if (hedgehogMode) return;
+ setGameDead(false);
+ }, [hedgehogMode]);
+
+ useEffect(() => {
+ if (!hedgehogMode || gameDead || !containerRef.current || !host) return;
let cancelled = false;
+ let losses = 0;
+ let remountTimer: ReturnType | null = null;
const container = containerRef.current;
const hedgehogConfig = user?.hedgehog_config as Record<
@@ -32,42 +44,95 @@ export function HedgehogMode() {
> | null;
const actorOptions = hedgehogConfig?.actor_options;
- host
- .mount(container, {
- actorOptions,
- onQuit: () => setHedgehogMode(false),
- })
- .then((handle) => {
- if (cancelled) {
- handle.destroy();
- return;
- }
- handleRef.current = handle;
- })
- .catch((err) => {
- log.error("Failed to mount hedgehog mode", err);
+ const destroyGame = () => {
+ try {
+ handleRef.current?.destroy();
+ } catch (err) {
+ log.error("Failed to destroy hedgehog mode game", err);
+ }
+ handleRef.current = null;
+ container.replaceChildren();
+ };
+
+ // A game whose rendering context died composites its full-window canvas
+ // as an opaque sheet over the whole app, so it must leave the DOM
+ // immediately.
+ const handleContextLost = () => {
+ if (!handleRef.current) return;
+ losses += 1;
+ log.error("Hedgehog mode WebGL context lost", { losses });
+ captureException(new Error("Hedgehog mode WebGL context lost"), {
+ source: "hedgehog-mode",
+ losses,
});
+ destroyGame();
+ if (losses > MAX_CONTEXT_LOSS_REMOUNTS) {
+ setGameDead(true);
+ return;
+ }
+ remountTimer = setTimeout(() => {
+ log.warn("Remounting hedgehog mode after WebGL context loss", {
+ attempt: losses,
+ });
+ mountGame();
+ }, REMOUNT_DELAY_MS);
+ };
- return () => {
- cancelled = true;
+ // Backup for a missed context-loss callback (e.g. swallowed across
+ // sleep/wake), so a dead canvas can never linger on screen undetected.
+ const checkContext = () => {
+ if (document.hidden) return;
+ if (handleRef.current?.isContextLost()) handleContextLost();
};
- }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode, host]);
- useEffect(() => {
+ const mountGame = () => {
+ if (cancelled || handleRef.current) return;
+ host
+ .mount(container, {
+ actorOptions,
+ onQuit: () => setHedgehogMode(false),
+ onContextLost: handleContextLost,
+ })
+ .then((handle) => {
+ if (cancelled) {
+ handle.destroy();
+ return;
+ }
+ handleRef.current = handle;
+ })
+ .catch((err) => {
+ log.error("Failed to mount hedgehog mode", err);
+ });
+ };
+
+ mountGame();
+ const contextCheckInterval = setInterval(
+ checkContext,
+ CONTEXT_CHECK_INTERVAL_MS,
+ );
+ const unsubscribeFocusCheck = useRendererWindowFocusStore.subscribe(
+ (state) => {
+ if (state.focused) checkContext();
+ },
+ );
+
return () => {
- if (handleRef.current) {
- handleRef.current.destroy();
- handleRef.current = null;
+ cancelled = true;
+ clearInterval(contextCheckInterval);
+ unsubscribeFocusCheck();
+ if (remountTimer) {
+ clearTimeout(remountTimer);
}
+ destroyGame();
};
- }, []);
+ }, [hedgehogMode, gameDead, user?.hedgehog_config, setHedgehogMode, host]);
return (
diff --git a/packages/ui/src/shell/hedgehogModeHost.ts b/packages/ui/src/shell/hedgehogModeHost.ts
index cb52862309..0890a5eed1 100644
--- a/packages/ui/src/shell/hedgehogModeHost.ts
+++ b/packages/ui/src/shell/hedgehogModeHost.ts
@@ -1,5 +1,7 @@
export interface HedgehogModeHandle {
destroy(): void;
+ /** True when the game's rendering context has died (e.g. GPU reset). */
+ isContextLost(): boolean;
}
export interface HedgehogModeMountOptions {
@@ -7,6 +9,8 @@ export interface HedgehogModeMountOptions {
actorOptions?: unknown;
/** Called when the user quits hedgehog mode from within the game. */
onQuit: () => void;
+ /** Called when the game's rendering context is lost, so the ui can tear down and remount. */
+ onContextLost?: () => void;
}
/**