diff --git a/apps/example/e2e/web.spec.ts b/apps/example/e2e/web.spec.ts index 7d208a2529..395b5cf656 100644 --- a/apps/example/e2e/web.spec.ts +++ b/apps/example/e2e/web.spec.ts @@ -78,3 +78,69 @@ for (const name of screens) { ).toEqual([]); }); } + +// The WebGL context behind a belongs to the element, the +// renderer to a layout effect, and the two lifetimes don't line up (#3976, +// #3349). The WebGLLifecycle screen exercises every way they can diverge; +// the reference canvas on it must keep a healthy context throughout. +test("WebGLLifecycle: context lifetime", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.stack ?? error.message)); + // Chrome reports evictions as a browser-generated console warning: + // "WARNING: Too many active WebGL contexts. Oldest context will be lost." + const evictions: string[] = []; + page.on("console", (message) => { + if (/WebGL contexts/i.test(message.text())) { + evictions.push(message.text()); + } + }); + await page.goto("/api/webgl-lifecycle"); + const status = page.getByTestId("status"); + const reference = page.getByTestId("reference").locator("canvas"); + await expect(reference).toBeVisible({ timeout: 60_000 }); + await expect(status).toContainText("reference: ok"); + + // Mounting and unmounting a canvas more times than the browser's context + // limit: each unmounted canvas must release its context right away, or the + // browser evicts the oldest live one, which is the reference canvas. + await page.getByTestId("remount").click(); + await expect(status).toContainText("cycles 20/20", { timeout: 30_000 }); + // Chrome's eviction runs on a timer after the context count is exceeded. + await page.waitForTimeout(500); + await expect(status).toContainText("reference: ok"); + expect(evictions).toEqual([]); + + // StrictMode re-runs the renderer's layout effect on the same element. + await page.getByTestId("strict").click(); + await expect(status).toContainText("strict: on"); + await expect(reference).toBeVisible(); + await expect(status).toContainText("reference: ok"); + + // The browser can take the context away and hand it back; the renderer + // must pick it up again. + await page.getByTestId("lose").click(); + await expect(status).toContainText("reference: LOST"); + await page.getByTestId("restore").click(); + await expect(status).toContainText("reference: ok"); + expect( + await reference.evaluate((canvas: HTMLCanvasElement) => { + // A restored context has a fresh drawing buffer: the renderer must have + // resized it (and hence rebuilt its surface) rather than left it empty. + return canvas.width > 0 && canvas.height > 0; + }) + ).toBe(true); + + // Live -> static -> live: each renderer kind needs its own element. + await page.getByTestId("renderer").click(); + await expect(status).toContainText("renderer: static"); + await expect(reference).toBeVisible(); + await page.getByTestId("renderer").click(); + await expect(status).toContainText("renderer: live"); + await expect(status).toContainText("reference: ok"); + + await page.screenshot({ + path: path.join(screenshotsDir, "WebGLLifecycle-actions.png"), + }); + await expect(status).not.toContainText("error:"); + expect(errors, errors.join("\n")).toEqual([]); +}); diff --git a/apps/example/src/Examples/API/Routes.ts b/apps/example/src/Examples/API/Routes.ts index e69add788b..3b42e7f5dc 100644 --- a/apps/example/src/Examples/API/Routes.ts +++ b/apps/example/src/Examples/API/Routes.ts @@ -42,5 +42,6 @@ export type Routes = { Web: undefined; WebLayout: undefined; WebGLContexts: undefined; + WebGLLifecycle: undefined; WebMemory: undefined; }; diff --git a/apps/example/src/Examples/API/Web.tsx b/apps/example/src/Examples/API/Web.tsx index e644757552..d920697631 100644 --- a/apps/example/src/Examples/API/Web.tsx +++ b/apps/example/src/Examples/API/Web.tsx @@ -14,6 +14,10 @@ const examples = [ screen: "WebGLContexts", title: "🔥 WebGL Contexts", }, + { + screen: "WebGLLifecycle", + title: "♻️ WebGL Lifecycle", + }, { screen: "WebMemory", title: "💧 WASM Memory", diff --git a/apps/example/src/Examples/API/WebGLLifecycle.tsx b/apps/example/src/Examples/API/WebGLLifecycle.tsx new file mode 100644 index 0000000000..29eaac22c5 --- /dev/null +++ b/apps/example/src/Examples/API/WebGLLifecycle.tsx @@ -0,0 +1,267 @@ +import React, { + Component, + StrictMode, + useEffect, + useRef, + useState, +} from "react"; +import type { ReactNode } from "react"; +import { + Button, + Platform, + ScrollView, + StyleSheet, + Text, + View, +} from "react-native"; +import { Canvas, Circle, Fill } from "@shopify/react-native-skia"; +import { + Easing, + useDerivedValue, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +// Exercises the lifetime of the WebGL context behind a on web. +// The context belongs to the element while the renderer belongs to +// a layout effect, and the two don't line up: +// - StrictMode (DEV) re-runs the layout effect on the same element (#3976): +// losing the context on cleanup left the canvas blank for good and made +// CanvasKit fault inside wasm on the next construction. +// - A canvas that really unmounts must lose its context right away, since a +// detached canvas keeps it alive until garbage collection and browsers cap +// the number of live contexts (16 in Chrome, which then evicts the oldest, +// visible or not) (#3349). +// - A context the browser evicted has to be picked up again once restored. +// - Switching between the live and the static renderer needs a fresh +// element, as an element is bound to one context kind for life. +// Every action below reports on the status line; on native they are no-ops. + +const SIZE = 200; +const CHURN_CYCLES = 20; + +const isWeb = Platform.OS === "web" && typeof document !== "undefined"; + +const referenceCanvas = () => + isWeb + ? document.querySelector( + '[data-testid="reference"] canvas' + ) + : null; + +const Spinner = () => { + const clock = useSharedValue(0); + useEffect(() => { + clock.value = withRepeat( + withTiming(1, { duration: 2000, easing: Easing.linear }), + -1 + ); + }, [clock]); + const cx = useDerivedValue( + () => SIZE / 2 + (SIZE / 3) * Math.cos(clock.value * Math.PI * 2) + ); + const cy = useDerivedValue( + () => SIZE / 2 + (SIZE / 3) * Math.sin(clock.value * Math.PI * 2) + ); + return ( + <> + + + + ); +}; + +interface BoundaryProps { + children: ReactNode; + onError: (message: string) => void; +} + +// Renderer failures throw from the layout effect that builds it, which would +// otherwise take the whole app down. +class Boundary extends Component { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: Error) { + this.props.onError(error.message); + } + + render() { + return this.state.failed ? null : this.props.children; + } +} + +export const WebGLLifecycle = () => { + const [strict, setStrict] = useState(false); + const [isStatic, setIsStatic] = useState(false); + const [churnMounted, setChurnMounted] = useState(false); + const [cycle, setCycle] = useState(0); + const [contextState, setContextState] = useState("unknown"); + const [error, setError] = useState(null); + const loseContextRef = useRef<{ + loseContext(): void; + restoreContext(): void; + } | null>(null); + + // Poll the reference canvas: a lost context is what an evicted or + // unrestored canvas looks like from the outside. + useEffect(() => { + if (!isWeb) { + return undefined; + } + const tick = () => { + const canvas = referenceCanvas(); + if (!canvas) { + setContextState("no canvas"); + return; + } + if (isStatic) { + setContextState("static"); + return; + } + const gl = canvas.getContext("webgl2"); + if (!gl) { + setContextState("none"); + } else { + setContextState(gl.isContextLost() ? "LOST" : "ok"); + } + }; + tick(); + const interval = setInterval(tick, 250); + return () => clearInterval(interval); + }, [isStatic, strict]); + + const remount = () => { + setError(null); + setCycle(0); + let i = 0; + const step = () => { + setChurnMounted(true); + setTimeout(() => { + setChurnMounted(false); + i++; + setCycle(i); + if (i < CHURN_CYCLES) { + setTimeout(step, 50); + } + }, 50); + }; + step(); + }; + + const lose = () => { + const gl = referenceCanvas()?.getContext("webgl2"); + // The extension object has to be obtained while the context is healthy: + // getExtension() returns null on a lost context. + loseContextRef.current = gl?.getExtension("WEBGL_lose_context") ?? null; + loseContextRef.current?.loseContext(); + }; + + const restore = () => { + loseContextRef.current?.restoreContext(); + }; + + const reference = ( + + + + + + ); + + return ( + + WebGL context lifecycle + + The reference canvas below must keep spinning through every action. Web + only. + + + {`cycles ${cycle}/${CHURN_CYCLES} · reference: ${contextState}` + + ` · strict: ${strict ? "on" : "off"}` + + ` · renderer: ${isStatic ? "static" : "live"}` + + (error ? ` · error: ${error}` : "")} + + + + {strict ? ( + {reference} + ) : ( + reference + )} + + + {churnMounted && ( + + + + + + )} + + + +