Skip to content

Commit 4fd5613

Browse files
authored
fix(ui): White screen when hedgehog mode loses its WebGL context (#3243)
1 parent 49cf9ee commit 4fd5613

4 files changed

Lines changed: 310 additions & 27 deletions

File tree

apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,23 @@ export class RendererHedgehogModeHost implements HedgehogModeHost {
2828

2929
await game.render(container);
3030

31+
const canvas = game.app.canvas;
32+
const notifyContextLost = () => options.onContextLost?.();
33+
canvas.addEventListener("webglcontextlost", notifyContextLost, {
34+
once: true,
35+
});
36+
3137
return {
32-
destroy: () => game.destroy(),
38+
destroy: () => {
39+
canvas.removeEventListener("webglcontextlost", notifyContextLost);
40+
game.destroy();
41+
},
42+
isContextLost: () => {
43+
const renderer = game.app.renderer as unknown as {
44+
context?: { isLost?: boolean };
45+
};
46+
return renderer.context?.isLost === true;
47+
},
3348
};
3449
}
3550
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { act, cleanup, render } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
mount: vi.fn(),
6+
destroy: vi.fn(),
7+
captureException: vi.fn(),
8+
contextLost: false,
9+
onContextLost: undefined as (() => void) | undefined,
10+
}));
11+
12+
const settingsState = vi.hoisted(() => ({
13+
hedgehogMode: true,
14+
setHedgehogMode: () => {},
15+
}));
16+
17+
vi.mock("@posthog/di/react", () => ({
18+
useService: () => ({ mount: mocks.mount }),
19+
}));
20+
21+
vi.mock("../features/auth/useMeQuery", () => ({
22+
useMeQuery: () => ({ data: undefined }),
23+
}));
24+
25+
vi.mock("../features/settings/settingsStore", () => ({
26+
useSettingsStore: (selector: (state: unknown) => unknown) =>
27+
selector(settingsState),
28+
}));
29+
30+
vi.mock("./analytics", () => ({
31+
captureException: mocks.captureException,
32+
}));
33+
34+
vi.mock("./logger", () => ({
35+
logger: {
36+
scope: () => ({
37+
error: vi.fn(),
38+
warn: vi.fn(),
39+
info: vi.fn(),
40+
debug: vi.fn(),
41+
}),
42+
},
43+
}));
44+
45+
import { HedgehogMode } from "./HedgehogMode";
46+
import { useRendererWindowFocusStore } from "./rendererWindowFocusStore";
47+
48+
function mountGameInto(
49+
container: HTMLDivElement,
50+
options: { onContextLost?: () => void },
51+
) {
52+
const canvas = document.createElement("canvas");
53+
container.appendChild(canvas);
54+
mocks.onContextLost = options.onContextLost;
55+
mocks.destroy.mockImplementation(() => canvas.remove());
56+
return Promise.resolve({
57+
destroy: mocks.destroy,
58+
isContextLost: () => mocks.contextLost,
59+
});
60+
}
61+
62+
async function loseContext() {
63+
mocks.contextLost = true;
64+
await act(async () => {
65+
mocks.onContextLost?.();
66+
});
67+
}
68+
69+
async function renderHedgehogMode() {
70+
const view = render(<HedgehogMode />);
71+
await act(async () => {});
72+
return { view, overlay: view.container.firstElementChild as HTMLDivElement };
73+
}
74+
75+
async function remountAfterDelay() {
76+
mocks.contextLost = false;
77+
await act(async () => {
78+
vi.advanceTimersByTime(2000);
79+
});
80+
}
81+
82+
beforeEach(() => {
83+
vi.useFakeTimers();
84+
mocks.mount.mockImplementation(mountGameInto);
85+
mocks.contextLost = false;
86+
settingsState.hedgehogMode = true;
87+
});
88+
89+
afterEach(() => {
90+
cleanup();
91+
vi.runOnlyPendingTimers();
92+
vi.useRealTimers();
93+
vi.clearAllMocks();
94+
});
95+
96+
describe("HedgehogMode", () => {
97+
it("mounts the game into the overlay container", async () => {
98+
const { overlay } = await renderHedgehogMode();
99+
100+
expect(mocks.mount).toHaveBeenCalledTimes(1);
101+
expect(overlay.querySelector("canvas")).not.toBeNull();
102+
expect(overlay.style.visibility).toBe("visible");
103+
});
104+
105+
it("destroys the game and reports when the context loss callback fires", async () => {
106+
const { overlay } = await renderHedgehogMode();
107+
108+
await loseContext();
109+
110+
expect(mocks.destroy).toHaveBeenCalledTimes(1);
111+
expect(overlay.querySelector("canvas")).toBeNull();
112+
expect(mocks.captureException).toHaveBeenCalledWith(
113+
expect.any(Error),
114+
expect.objectContaining({ source: "hedgehog-mode", losses: 1 }),
115+
);
116+
});
117+
118+
it("tears down when polling detects a lost context without a callback", async () => {
119+
const { overlay } = await renderHedgehogMode();
120+
121+
mocks.contextLost = true;
122+
await act(async () => {
123+
vi.advanceTimersByTime(10_000);
124+
});
125+
126+
expect(mocks.destroy).toHaveBeenCalledTimes(1);
127+
expect(overlay.querySelector("canvas")).toBeNull();
128+
expect(mocks.captureException).toHaveBeenCalledTimes(1);
129+
});
130+
131+
it("tears down when a lost context is detected on window focus", async () => {
132+
const { overlay } = await renderHedgehogMode();
133+
134+
mocks.contextLost = true;
135+
await act(async () => {
136+
useRendererWindowFocusStore.setState({ focused: true });
137+
});
138+
139+
expect(mocks.destroy).toHaveBeenCalledTimes(1);
140+
expect(overlay.querySelector("canvas")).toBeNull();
141+
});
142+
143+
it("remounts the game after the context loss delay", async () => {
144+
const { overlay } = await renderHedgehogMode();
145+
146+
await loseContext();
147+
await remountAfterDelay();
148+
149+
expect(mocks.mount).toHaveBeenCalledTimes(2);
150+
expect(overlay.querySelector("canvas")).not.toBeNull();
151+
expect(overlay.style.visibility).toBe("visible");
152+
});
153+
154+
it("hides the overlay after repeated context losses", async () => {
155+
const { overlay } = await renderHedgehogMode();
156+
157+
for (let loss = 0; loss < 4; loss += 1) {
158+
await loseContext();
159+
await remountAfterDelay();
160+
}
161+
162+
expect(mocks.mount).toHaveBeenCalledTimes(4);
163+
expect(overlay.querySelector("canvas")).toBeNull();
164+
expect(overlay.style.visibility).toBe("hidden");
165+
166+
await act(async () => {
167+
vi.advanceTimersByTime(10_000);
168+
});
169+
expect(mocks.mount).toHaveBeenCalledTimes(4);
170+
});
171+
172+
it("destroys the game on toggle off and remounts armed on re-enable", async () => {
173+
const { view, overlay } = await renderHedgehogMode();
174+
expect(mocks.mount).toHaveBeenCalledTimes(1);
175+
176+
settingsState.hedgehogMode = false;
177+
view.rerender(<HedgehogMode />);
178+
expect(mocks.destroy).toHaveBeenCalledTimes(1);
179+
expect(overlay.querySelector("canvas")).toBeNull();
180+
expect(overlay.style.visibility).toBe("hidden");
181+
182+
settingsState.hedgehogMode = true;
183+
view.rerender(<HedgehogMode />);
184+
await act(async () => {});
185+
expect(mocks.mount).toHaveBeenCalledTimes(2);
186+
187+
await loseContext();
188+
expect(mocks.captureException).toHaveBeenCalledTimes(1);
189+
expect(overlay.querySelector("canvas")).toBeNull();
190+
});
191+
192+
it("destroys the game on unmount", async () => {
193+
const { view } = await renderHedgehogMode();
194+
195+
view.unmount();
196+
197+
expect(mocks.destroy).toHaveBeenCalledTimes(1);
198+
});
199+
});

packages/ui/src/shell/HedgehogMode.tsx

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { useService } from "@posthog/di/react";
2-
import { useEffect, useRef } from "react";
2+
import { useEffect, useRef, useState } from "react";
33
import { useMeQuery } from "../features/auth/useMeQuery";
44
import { useSettingsStore } from "../features/settings/settingsStore";
5+
import { captureException } from "./analytics";
56
import {
67
HEDGEHOG_MODE_HOST,
78
type HedgehogModeHandle,
89
type HedgehogModeHost,
910
} from "./hedgehogModeHost";
1011
import { logger } from "./logger";
12+
import { useRendererWindowFocusStore } from "./rendererWindowFocusStore";
1113

1214
const log = logger.scope("hedgehog-mode");
15+
const MAX_CONTEXT_LOSS_REMOUNTS = 3;
16+
const REMOUNT_DELAY_MS = 2000;
17+
const CONTEXT_CHECK_INTERVAL_MS = 10_000;
1318

1419
export function HedgehogMode() {
1520
const hedgehogMode = useSettingsStore((s) => s.hedgehogMode);
@@ -18,12 +23,19 @@ export function HedgehogMode() {
1823
const host = useService<HedgehogModeHost>(HEDGEHOG_MODE_HOST);
1924
const containerRef = useRef<HTMLDivElement>(null);
2025
const handleRef = useRef<HedgehogModeHandle | null>(null);
26+
const [gameDead, setGameDead] = useState(false);
2127

2228
useEffect(() => {
23-
if (!hedgehogMode || !containerRef.current || handleRef.current) return;
24-
if (!host) return;
29+
if (hedgehogMode) return;
30+
setGameDead(false);
31+
}, [hedgehogMode]);
32+
33+
useEffect(() => {
34+
if (!hedgehogMode || gameDead || !containerRef.current || !host) return;
2535

2636
let cancelled = false;
37+
let losses = 0;
38+
let remountTimer: ReturnType<typeof setTimeout> | null = null;
2739
const container = containerRef.current;
2840

2941
const hedgehogConfig = user?.hedgehog_config as Record<
@@ -32,42 +44,95 @@ export function HedgehogMode() {
3244
> | null;
3345
const actorOptions = hedgehogConfig?.actor_options;
3446

35-
host
36-
.mount(container, {
37-
actorOptions,
38-
onQuit: () => setHedgehogMode(false),
39-
})
40-
.then((handle) => {
41-
if (cancelled) {
42-
handle.destroy();
43-
return;
44-
}
45-
handleRef.current = handle;
46-
})
47-
.catch((err) => {
48-
log.error("Failed to mount hedgehog mode", err);
47+
const destroyGame = () => {
48+
try {
49+
handleRef.current?.destroy();
50+
} catch (err) {
51+
log.error("Failed to destroy hedgehog mode game", err);
52+
}
53+
handleRef.current = null;
54+
container.replaceChildren();
55+
};
56+
57+
// A game whose rendering context died composites its full-window canvas
58+
// as an opaque sheet over the whole app, so it must leave the DOM
59+
// immediately.
60+
const handleContextLost = () => {
61+
if (!handleRef.current) return;
62+
losses += 1;
63+
log.error("Hedgehog mode WebGL context lost", { losses });
64+
captureException(new Error("Hedgehog mode WebGL context lost"), {
65+
source: "hedgehog-mode",
66+
losses,
4967
});
68+
destroyGame();
69+
if (losses > MAX_CONTEXT_LOSS_REMOUNTS) {
70+
setGameDead(true);
71+
return;
72+
}
73+
remountTimer = setTimeout(() => {
74+
log.warn("Remounting hedgehog mode after WebGL context loss", {
75+
attempt: losses,
76+
});
77+
mountGame();
78+
}, REMOUNT_DELAY_MS);
79+
};
5080

51-
return () => {
52-
cancelled = true;
81+
// Backup for a missed context-loss callback (e.g. swallowed across
82+
// sleep/wake), so a dead canvas can never linger on screen undetected.
83+
const checkContext = () => {
84+
if (document.hidden) return;
85+
if (handleRef.current?.isContextLost()) handleContextLost();
5386
};
54-
}, [hedgehogMode, user?.hedgehog_config, setHedgehogMode, host]);
5587

56-
useEffect(() => {
88+
const mountGame = () => {
89+
if (cancelled || handleRef.current) return;
90+
host
91+
.mount(container, {
92+
actorOptions,
93+
onQuit: () => setHedgehogMode(false),
94+
onContextLost: handleContextLost,
95+
})
96+
.then((handle) => {
97+
if (cancelled) {
98+
handle.destroy();
99+
return;
100+
}
101+
handleRef.current = handle;
102+
})
103+
.catch((err) => {
104+
log.error("Failed to mount hedgehog mode", err);
105+
});
106+
};
107+
108+
mountGame();
109+
const contextCheckInterval = setInterval(
110+
checkContext,
111+
CONTEXT_CHECK_INTERVAL_MS,
112+
);
113+
const unsubscribeFocusCheck = useRendererWindowFocusStore.subscribe(
114+
(state) => {
115+
if (state.focused) checkContext();
116+
},
117+
);
118+
57119
return () => {
58-
if (handleRef.current) {
59-
handleRef.current.destroy();
60-
handleRef.current = null;
120+
cancelled = true;
121+
clearInterval(contextCheckInterval);
122+
unsubscribeFocusCheck();
123+
if (remountTimer) {
124+
clearTimeout(remountTimer);
61125
}
126+
destroyGame();
62127
};
63-
}, []);
128+
}, [hedgehogMode, gameDead, user?.hedgehog_config, setHedgehogMode, host]);
64129

65130
return (
66131
<div
67132
ref={containerRef}
68133
style={{
69134
zIndex: 999998,
70-
visibility: hedgehogMode ? "visible" : "hidden",
135+
visibility: hedgehogMode && !gameDead ? "visible" : "hidden",
71136
}}
72137
className="pointer-events-none fixed inset-0"
73138
/>

0 commit comments

Comments
 (0)