From 96907c5b06633dd46204337386ff7695bffbb757 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 7 Jul 2026 11:32:53 -0700 Subject: [PATCH 1/3] remount hedgehog mode on WebGL context loss --- packages/ui/src/shell/HedgehogMode.test.tsx | 136 ++++++++++++++++++++ packages/ui/src/shell/HedgehogMode.tsx | 80 +++++++++++- 2 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/shell/HedgehogMode.test.tsx diff --git a/packages/ui/src/shell/HedgehogMode.test.tsx b/packages/ui/src/shell/HedgehogMode.test.tsx new file mode 100644 index 0000000000..ad09e0cbdf --- /dev/null +++ b/packages/ui/src/shell/HedgehogMode.test.tsx @@ -0,0 +1,136 @@ +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(), +})); + +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({ hedgehogMode: true, setHedgehogMode: vi.fn() }), +})); + +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"; + +function mountGameInto(container: HTMLDivElement) { + const canvas = document.createElement("canvas"); + container.appendChild(canvas); + mocks.destroy.mockImplementation(() => canvas.remove()); + return Promise.resolve({ destroy: mocks.destroy }); +} + +async function loseContext(overlay: HTMLDivElement) { + const canvas = overlay.querySelector("canvas"); + expect(canvas).not.toBeNull(); + await act(async () => { + canvas?.dispatchEvent(new Event("webglcontextlost")); + }); +} + +async function renderHedgehogMode() { + const view = render(); + await act(async () => {}); + return view.container.firstElementChild as HTMLDivElement; +} + +beforeEach(() => { + vi.useFakeTimers(); + mocks.mount.mockImplementation(mountGameInto); +}); + +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 WebGL context is lost", async () => { + const overlay = await renderHedgehogMode(); + + await loseContext(overlay); + + 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("remounts the game after the context loss delay", async () => { + const overlay = await renderHedgehogMode(); + + await loseContext(overlay); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + 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(overlay); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + + expect(mocks.mount).toHaveBeenCalledTimes(4); + expect(overlay.querySelector("canvas")).toBeNull(); + expect(overlay.style.visibility).toBe("hidden"); + + await act(async () => { + vi.advanceTimersByTime(10000); + }); + expect(mocks.mount).toHaveBeenCalledTimes(4); + }); + + it("destroys the game on unmount", async () => { + const view = render(); + await act(async () => {}); + + 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..600aea0a88 100644 --- a/packages/ui/src/shell/HedgehogMode.tsx +++ b/packages/ui/src/shell/HedgehogMode.tsx @@ -1,7 +1,8 @@ import { useService } from "@posthog/di/react"; -import { useEffect, useRef } from "react"; +import { type RefObject, 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, @@ -10,6 +11,21 @@ import { import { logger } from "./logger"; const log = logger.scope("hedgehog-mode"); +const MAX_CONTEXT_LOSS_REMOUNTS = 3; +const REMOUNT_DELAY_MS = 2000; + +function destroyGame( + handleRef: RefObject, + container: HTMLDivElement | null, +) { + try { + handleRef.current?.destroy(); + } catch (err) { + log.error("Failed to destroy hedgehog mode game", err); + } + handleRef.current = null; + container?.replaceChildren(); +} export function HedgehogMode() { const hedgehogMode = useSettingsStore((s) => s.hedgehogMode); @@ -18,14 +34,52 @@ export function HedgehogMode() { const host = useService(HEDGEHOG_MODE_HOST); const containerRef = useRef(null); const handleRef = useRef(null); + const contextLossesRef = useRef(0); + const remountTimerRef = useRef | null>(null); + const [remountAttempt, setRemountAttempt] = useState(0); + const [gameDead, setGameDead] = useState(false); useEffect(() => { - if (!hedgehogMode || !containerRef.current || handleRef.current) return; + if (hedgehogMode) return; + contextLossesRef.current = 0; + setGameDead(false); + }, [hedgehogMode]); + + useEffect(() => { + if (!hedgehogMode || gameDead || !containerRef.current || handleRef.current) + return; if (!host) return; let cancelled = false; + let canvas: HTMLCanvasElement | null = null; const container = containerRef.current; + // A game whose WebGL context died composites its full-window canvas as an + // opaque sheet over the whole app, so it must leave the DOM immediately. + const handleContextLost = () => { + contextLossesRef.current += 1; + const losses = contextLossesRef.current; + log.error("Hedgehog mode WebGL context lost", { losses }); + captureException(new Error("Hedgehog mode WebGL context lost"), { + source: "hedgehog-mode", + losses, + }); + destroyGame(handleRef, container); + if (losses > MAX_CONTEXT_LOSS_REMOUNTS) { + setGameDead(true); + return; + } + remountTimerRef.current = setTimeout(() => { + setRemountAttempt(losses); + }, REMOUNT_DELAY_MS); + }; + + if (remountAttempt > 0) { + log.warn("Remounting hedgehog mode after WebGL context loss", { + attempt: remountAttempt, + }); + } + const hedgehogConfig = user?.hedgehog_config as Record< string, unknown @@ -43,6 +97,10 @@ export function HedgehogMode() { return; } handleRef.current = handle; + canvas = container.querySelector("canvas"); + canvas?.addEventListener("webglcontextlost", handleContextLost, { + once: true, + }); }) .catch((err) => { log.error("Failed to mount hedgehog mode", err); @@ -50,15 +108,23 @@ export function HedgehogMode() { return () => { cancelled = true; + canvas?.removeEventListener("webglcontextlost", handleContextLost); }; - }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode, host]); + }, [ + hedgehogMode, + gameDead, + remountAttempt, + user?.hedgehog_config, + setHedgehogMode, + host, + ]); useEffect(() => { return () => { - if (handleRef.current) { - handleRef.current.destroy(); - handleRef.current = null; + if (remountTimerRef.current) { + clearTimeout(remountTimerRef.current); } + destroyGame(handleRef, containerRef.current); }; }, []); @@ -67,7 +133,7 @@ export function HedgehogMode() { ref={containerRef} style={{ zIndex: 999998, - visibility: hedgehogMode ? "visible" : "hidden", + visibility: hedgehogMode && !gameDead ? "visible" : "hidden", }} className="pointer-events-none fixed inset-0" /> From 2a9692ca4c59b968f756bb2fa60d5d5cce29a6fc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 7 Jul 2026 11:55:05 -0700 Subject: [PATCH 2/3] harden hedgehog mode against missed context loss --- packages/ui/src/shell/HedgehogMode.test.tsx | 62 +++++++++- packages/ui/src/shell/HedgehogMode.tsx | 127 +++++++++++--------- 2 files changed, 130 insertions(+), 59 deletions(-) diff --git a/packages/ui/src/shell/HedgehogMode.test.tsx b/packages/ui/src/shell/HedgehogMode.test.tsx index ad09e0cbdf..fbb9180146 100644 --- a/packages/ui/src/shell/HedgehogMode.test.tsx +++ b/packages/ui/src/shell/HedgehogMode.test.tsx @@ -5,6 +5,12 @@ const mocks = vi.hoisted(() => ({ mount: vi.fn(), destroy: vi.fn(), captureException: vi.fn(), + contextLost: false, +})); + +const settingsState = vi.hoisted(() => ({ + hedgehogMode: true, + setHedgehogMode: () => {}, })); vi.mock("@posthog/di/react", () => ({ @@ -17,7 +23,7 @@ vi.mock("../features/auth/useMeQuery", () => ({ vi.mock("../features/settings/settingsStore", () => ({ useSettingsStore: (selector: (state: unknown) => unknown) => - selector({ hedgehogMode: true, setHedgehogMode: vi.fn() }), + selector(settingsState), })); vi.mock("./analytics", () => ({ @@ -39,6 +45,9 @@ import { HedgehogMode } from "./HedgehogMode"; function mountGameInto(container: HTMLDivElement) { const canvas = document.createElement("canvas"); + canvas.getContext = (() => ({ + isContextLost: () => mocks.contextLost, + })) as unknown as HTMLCanvasElement["getContext"]; container.appendChild(canvas); mocks.destroy.mockImplementation(() => canvas.remove()); return Promise.resolve({ destroy: mocks.destroy }); @@ -61,6 +70,8 @@ async function renderHedgehogMode() { beforeEach(() => { vi.useFakeTimers(); mocks.mount.mockImplementation(mountGameInto); + mocks.contextLost = false; + settingsState.hedgehogMode = true; }); afterEach(() => { @@ -92,6 +103,31 @@ describe("HedgehogMode", () => { ); }); + it("tears down when polling detects a lost context without an event", 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 () => { + window.dispatchEvent(new Event("focus")); + }); + + expect(mocks.destroy).toHaveBeenCalledTimes(1); + expect(overlay.querySelector("canvas")).toBeNull(); + }); + it("remounts the game after the context loss delay", async () => { const overlay = await renderHedgehogMode(); @@ -120,11 +156,33 @@ describe("HedgehogMode", () => { expect(overlay.style.visibility).toBe("hidden"); await act(async () => { - vi.advanceTimersByTime(10000); + 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 = render(); + await act(async () => {}); + expect(mocks.mount).toHaveBeenCalledTimes(1); + const overlay = view.container.firstElementChild as HTMLDivElement; + + 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(overlay); + expect(mocks.captureException).toHaveBeenCalledTimes(1); + expect(overlay.querySelector("canvas")).toBeNull(); + }); + it("destroys the game on unmount", async () => { const view = render(); await act(async () => {}); diff --git a/packages/ui/src/shell/HedgehogMode.tsx b/packages/ui/src/shell/HedgehogMode.tsx index 600aea0a88..a8381e469f 100644 --- a/packages/ui/src/shell/HedgehogMode.tsx +++ b/packages/ui/src/shell/HedgehogMode.tsx @@ -13,6 +13,7 @@ import { logger } from "./logger"; const log = logger.scope("hedgehog-mode"); const MAX_CONTEXT_LOSS_REMOUNTS = 3; const REMOUNT_DELAY_MS = 2000; +const CONTEXT_CHECK_INTERVAL_MS = 10_000; function destroyGame( handleRef: RefObject, @@ -34,31 +35,35 @@ export function HedgehogMode() { const host = useService(HEDGEHOG_MODE_HOST); const containerRef = useRef(null); const handleRef = useRef(null); - const contextLossesRef = useRef(0); - const remountTimerRef = useRef | null>(null); - const [remountAttempt, setRemountAttempt] = useState(0); const [gameDead, setGameDead] = useState(false); useEffect(() => { if (hedgehogMode) return; - contextLossesRef.current = 0; setGameDead(false); }, [hedgehogMode]); useEffect(() => { - if (!hedgehogMode || gameDead || !containerRef.current || handleRef.current) - return; - if (!host) return; + if (!hedgehogMode || gameDead || !containerRef.current || !host) return; let cancelled = false; + let lost = false; + let losses = 0; let canvas: HTMLCanvasElement | null = null; + let remountTimer: ReturnType | null = null; const container = containerRef.current; + const hedgehogConfig = user?.hedgehog_config as Record< + string, + unknown + > | null; + const actorOptions = hedgehogConfig?.actor_options; + // A game whose WebGL context died composites its full-window canvas as an // opaque sheet over the whole app, so it must leave the DOM immediately. const handleContextLost = () => { - contextLossesRef.current += 1; - const losses = contextLossesRef.current; + if (lost) return; + lost = true; + losses += 1; log.error("Hedgehog mode WebGL context lost", { losses }); captureException(new Error("Hedgehog mode WebGL context lost"), { source: "hedgehog-mode", @@ -69,64 +74,72 @@ export function HedgehogMode() { setGameDead(true); return; } - remountTimerRef.current = setTimeout(() => { - setRemountAttempt(losses); - }, REMOUNT_DELAY_MS); + remountTimer = setTimeout(mountGame, REMOUNT_DELAY_MS); }; - if (remountAttempt > 0) { - log.warn("Remounting hedgehog mode after WebGL context loss", { - attempt: remountAttempt, - }); - } + const isCanvasContextLost = () => { + if (!canvas) return false; + const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); + return gl?.isContextLost() ?? false; + }; - const hedgehogConfig = user?.hedgehog_config as Record< - string, - unknown - > | null; - const actorOptions = hedgehogConfig?.actor_options; + // Backup for a missed webglcontextlost event (e.g. swallowed across + // sleep/wake), so a dead canvas can never linger on screen undetected. + const checkContext = () => { + if (!lost && isCanvasContextLost()) { + handleContextLost(); + } + }; - host - .mount(container, { - actorOptions, - onQuit: () => setHedgehogMode(false), - }) - .then((handle) => { - if (cancelled) { - handle.destroy(); - return; - } - handleRef.current = handle; - canvas = container.querySelector("canvas"); - canvas?.addEventListener("webglcontextlost", handleContextLost, { - once: true, + const mountGame = () => { + if (cancelled || handleRef.current) return; + if (losses > 0) { + log.warn("Remounting hedgehog mode after WebGL context loss", { + attempt: losses, + }); + } + host + .mount(container, { + actorOptions, + onQuit: () => setHedgehogMode(false), + }) + .then((handle) => { + if (cancelled) { + handle.destroy(); + return; + } + handleRef.current = handle; + lost = false; + canvas = container.querySelector("canvas"); + canvas?.addEventListener("webglcontextlost", handleContextLost, { + once: true, + }); + }) + .catch((err) => { + log.error("Failed to mount hedgehog mode", err); }); - }) - .catch((err) => { - log.error("Failed to mount hedgehog mode", err); - }); - - return () => { - cancelled = true; - canvas?.removeEventListener("webglcontextlost", handleContextLost); }; - }, [ - hedgehogMode, - gameDead, - remountAttempt, - user?.hedgehog_config, - setHedgehogMode, - host, - ]); - useEffect(() => { + mountGame(); + const contextCheckInterval = setInterval( + checkContext, + CONTEXT_CHECK_INTERVAL_MS, + ); + window.addEventListener("focus", checkContext); + document.addEventListener("visibilitychange", checkContext); + return () => { - if (remountTimerRef.current) { - clearTimeout(remountTimerRef.current); + cancelled = true; + clearInterval(contextCheckInterval); + window.removeEventListener("focus", checkContext); + document.removeEventListener("visibilitychange", checkContext); + if (remountTimer) { + clearTimeout(remountTimer); } - destroyGame(handleRef, containerRef.current); + canvas?.removeEventListener("webglcontextlost", handleContextLost); + destroyGame(handleRef, container); }; - }, []); + }, [hedgehogMode, gameDead, user?.hedgehog_config, setHedgehogMode, host]); return (
Date: Tue, 7 Jul 2026 12:09:53 -0700 Subject: [PATCH 3/3] move context loss detection behind host seam --- .../platform-adapters/hedgehog-mode-host.ts | 17 +++- packages/ui/src/shell/HedgehogMode.test.tsx | 73 +++++++++-------- packages/ui/src/shell/HedgehogMode.tsx | 82 ++++++++----------- packages/ui/src/shell/hedgehogModeHost.ts | 4 + 4 files changed, 93 insertions(+), 83 deletions(-) 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 index fbb9180146..1bcfada5f6 100644 --- a/packages/ui/src/shell/HedgehogMode.test.tsx +++ b/packages/ui/src/shell/HedgehogMode.test.tsx @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ destroy: vi.fn(), captureException: vi.fn(), contextLost: false, + onContextLost: undefined as (() => void) | undefined, })); const settingsState = vi.hoisted(() => ({ @@ -42,29 +43,40 @@ vi.mock("./logger", () => ({ })); import { HedgehogMode } from "./HedgehogMode"; +import { useRendererWindowFocusStore } from "./rendererWindowFocusStore"; -function mountGameInto(container: HTMLDivElement) { +function mountGameInto( + container: HTMLDivElement, + options: { onContextLost?: () => void }, +) { const canvas = document.createElement("canvas"); - canvas.getContext = (() => ({ - isContextLost: () => mocks.contextLost, - })) as unknown as HTMLCanvasElement["getContext"]; container.appendChild(canvas); + mocks.onContextLost = options.onContextLost; mocks.destroy.mockImplementation(() => canvas.remove()); - return Promise.resolve({ destroy: mocks.destroy }); + return Promise.resolve({ + destroy: mocks.destroy, + isContextLost: () => mocks.contextLost, + }); } -async function loseContext(overlay: HTMLDivElement) { - const canvas = overlay.querySelector("canvas"); - expect(canvas).not.toBeNull(); +async function loseContext() { + mocks.contextLost = true; await act(async () => { - canvas?.dispatchEvent(new Event("webglcontextlost")); + mocks.onContextLost?.(); }); } async function renderHedgehogMode() { const view = render(); await act(async () => {}); - return view.container.firstElementChild as HTMLDivElement; + return { view, overlay: view.container.firstElementChild as HTMLDivElement }; +} + +async function remountAfterDelay() { + mocks.contextLost = false; + await act(async () => { + vi.advanceTimersByTime(2000); + }); } beforeEach(() => { @@ -83,17 +95,17 @@ afterEach(() => { describe("HedgehogMode", () => { it("mounts the game into the overlay container", async () => { - const overlay = await renderHedgehogMode(); + 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 WebGL context is lost", async () => { - const overlay = await renderHedgehogMode(); + it("destroys the game and reports when the context loss callback fires", async () => { + const { overlay } = await renderHedgehogMode(); - await loseContext(overlay); + await loseContext(); expect(mocks.destroy).toHaveBeenCalledTimes(1); expect(overlay.querySelector("canvas")).toBeNull(); @@ -103,8 +115,8 @@ describe("HedgehogMode", () => { ); }); - it("tears down when polling detects a lost context without an event", async () => { - const overlay = await renderHedgehogMode(); + it("tears down when polling detects a lost context without a callback", async () => { + const { overlay } = await renderHedgehogMode(); mocks.contextLost = true; await act(async () => { @@ -117,11 +129,11 @@ describe("HedgehogMode", () => { }); it("tears down when a lost context is detected on window focus", async () => { - const overlay = await renderHedgehogMode(); + const { overlay } = await renderHedgehogMode(); mocks.contextLost = true; await act(async () => { - window.dispatchEvent(new Event("focus")); + useRendererWindowFocusStore.setState({ focused: true }); }); expect(mocks.destroy).toHaveBeenCalledTimes(1); @@ -129,12 +141,10 @@ describe("HedgehogMode", () => { }); it("remounts the game after the context loss delay", async () => { - const overlay = await renderHedgehogMode(); + const { overlay } = await renderHedgehogMode(); - await loseContext(overlay); - await act(async () => { - vi.advanceTimersByTime(2000); - }); + await loseContext(); + await remountAfterDelay(); expect(mocks.mount).toHaveBeenCalledTimes(2); expect(overlay.querySelector("canvas")).not.toBeNull(); @@ -142,13 +152,11 @@ describe("HedgehogMode", () => { }); it("hides the overlay after repeated context losses", async () => { - const overlay = await renderHedgehogMode(); + const { overlay } = await renderHedgehogMode(); for (let loss = 0; loss < 4; loss += 1) { - await loseContext(overlay); - await act(async () => { - vi.advanceTimersByTime(2000); - }); + await loseContext(); + await remountAfterDelay(); } expect(mocks.mount).toHaveBeenCalledTimes(4); @@ -162,10 +170,8 @@ describe("HedgehogMode", () => { }); it("destroys the game on toggle off and remounts armed on re-enable", async () => { - const view = render(); - await act(async () => {}); + const { view, overlay } = await renderHedgehogMode(); expect(mocks.mount).toHaveBeenCalledTimes(1); - const overlay = view.container.firstElementChild as HTMLDivElement; settingsState.hedgehogMode = false; view.rerender(); @@ -178,14 +184,13 @@ describe("HedgehogMode", () => { await act(async () => {}); expect(mocks.mount).toHaveBeenCalledTimes(2); - await loseContext(overlay); + await loseContext(); expect(mocks.captureException).toHaveBeenCalledTimes(1); expect(overlay.querySelector("canvas")).toBeNull(); }); it("destroys the game on unmount", async () => { - const view = render(); - await act(async () => {}); + const { view } = await renderHedgehogMode(); view.unmount(); diff --git a/packages/ui/src/shell/HedgehogMode.tsx b/packages/ui/src/shell/HedgehogMode.tsx index a8381e469f..179825964c 100644 --- a/packages/ui/src/shell/HedgehogMode.tsx +++ b/packages/ui/src/shell/HedgehogMode.tsx @@ -1,5 +1,5 @@ import { useService } from "@posthog/di/react"; -import { type RefObject, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useMeQuery } from "../features/auth/useMeQuery"; import { useSettingsStore } from "../features/settings/settingsStore"; import { captureException } from "./analytics"; @@ -9,25 +9,13 @@ import { 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; -function destroyGame( - handleRef: RefObject, - container: HTMLDivElement | null, -) { - try { - handleRef.current?.destroy(); - } catch (err) { - log.error("Failed to destroy hedgehog mode game", err); - } - handleRef.current = null; - container?.replaceChildren(); -} - export function HedgehogMode() { const hedgehogMode = useSettingsStore((s) => s.hedgehogMode); const setHedgehogMode = useSettingsStore((s) => s.setHedgehogMode); @@ -46,9 +34,7 @@ export function HedgehogMode() { if (!hedgehogMode || gameDead || !containerRef.current || !host) return; let cancelled = false; - let lost = false; let losses = 0; - let canvas: HTMLCanvasElement | null = null; let remountTimer: ReturnType | null = null; const container = containerRef.current; @@ -58,50 +44,54 @@ export function HedgehogMode() { > | null; const actorOptions = hedgehogConfig?.actor_options; - // A game whose WebGL context died composites its full-window canvas as an - // opaque sheet over the whole app, so it must leave the DOM immediately. + 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 (lost) return; - lost = true; + 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(handleRef, container); + destroyGame(); if (losses > MAX_CONTEXT_LOSS_REMOUNTS) { setGameDead(true); return; } - remountTimer = setTimeout(mountGame, REMOUNT_DELAY_MS); - }; - - const isCanvasContextLost = () => { - if (!canvas) return false; - const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl"); - return gl?.isContextLost() ?? false; + remountTimer = setTimeout(() => { + log.warn("Remounting hedgehog mode after WebGL context loss", { + attempt: losses, + }); + mountGame(); + }, REMOUNT_DELAY_MS); }; - // Backup for a missed webglcontextlost event (e.g. swallowed across + // 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 (!lost && isCanvasContextLost()) { - handleContextLost(); - } + if (document.hidden) return; + if (handleRef.current?.isContextLost()) handleContextLost(); }; const mountGame = () => { if (cancelled || handleRef.current) return; - if (losses > 0) { - log.warn("Remounting hedgehog mode after WebGL context loss", { - attempt: losses, - }); - } host .mount(container, { actorOptions, onQuit: () => setHedgehogMode(false), + onContextLost: handleContextLost, }) .then((handle) => { if (cancelled) { @@ -109,11 +99,6 @@ export function HedgehogMode() { return; } handleRef.current = handle; - lost = false; - canvas = container.querySelector("canvas"); - canvas?.addEventListener("webglcontextlost", handleContextLost, { - once: true, - }); }) .catch((err) => { log.error("Failed to mount hedgehog mode", err); @@ -125,19 +110,20 @@ export function HedgehogMode() { checkContext, CONTEXT_CHECK_INTERVAL_MS, ); - window.addEventListener("focus", checkContext); - document.addEventListener("visibilitychange", checkContext); + const unsubscribeFocusCheck = useRendererWindowFocusStore.subscribe( + (state) => { + if (state.focused) checkContext(); + }, + ); return () => { cancelled = true; clearInterval(contextCheckInterval); - window.removeEventListener("focus", checkContext); - document.removeEventListener("visibilitychange", checkContext); + unsubscribeFocusCheck(); if (remountTimer) { clearTimeout(remountTimer); } - canvas?.removeEventListener("webglcontextlost", handleContextLost); - destroyGame(handleRef, container); + destroyGame(); }; }, [hedgehogMode, gameDead, user?.hedgehog_config, setHedgehogMode, host]); 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; } /**