Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/example/e2e/web.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,69 @@ for (const name of screens) {
).toEqual([]);
});
}

// The WebGL context behind a <Canvas> belongs to the <canvas> 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([]);
});
1 change: 1 addition & 0 deletions apps/example/src/Examples/API/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,6 @@ export type Routes = {
Web: undefined;
WebLayout: undefined;
WebGLContexts: undefined;
WebGLLifecycle: undefined;
WebMemory: undefined;
};
4 changes: 4 additions & 0 deletions apps/example/src/Examples/API/Web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const examples = [
screen: "WebGLContexts",
title: "πŸ”₯ WebGL Contexts",
},
{
screen: "WebGLLifecycle",
title: "♻️ WebGL Lifecycle",
},
{
screen: "WebMemory",
title: "πŸ’§ WASM Memory",
Expand Down
267 changes: 267 additions & 0 deletions apps/example/src/Examples/API/WebGLLifecycle.tsx
Original file line number Diff line number Diff line change
@@ -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 <Canvas> on web.
// The context belongs to the <canvas> 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<HTMLCanvasElement>(
'[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 (
<>
<Fill color="#1c2541" />
<Circle cx={cx} cy={cy} r={SIZE / 10} color="#5bc0be" />
</>
);
};

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<BoundaryProps, { failed: boolean }> {
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<string | null>(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 = (
<Boundary onError={setError}>
<Canvas style={styles.canvas} __destroyWebGLContextAfterRender={isStatic}>
<Spinner />
</Canvas>
</Boundary>
);

return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.header}>WebGL context lifecycle</Text>
<Text style={styles.description}>
The reference canvas below must keep spinning through every action. Web
only.
</Text>
<Text style={styles.status} testID="status">
{`cycles ${cycle}/${CHURN_CYCLES} Β· reference: ${contextState}` +
` Β· strict: ${strict ? "on" : "off"}` +
` Β· renderer: ${isStatic ? "static" : "live"}` +
(error ? ` Β· error: ${error}` : "")}
</Text>
<View style={styles.row}>
<View testID="reference">
{strict ? (
<StrictMode key="strict">{reference}</StrictMode>
) : (
reference
)}
</View>
<View testID="churn" style={styles.canvas}>
{churnMounted && (
<Boundary onError={setError}>
<Canvas style={styles.canvas}>
<Fill color={`hsl(${(cycle * 47) % 360}, 70%, 50%)`} />
</Canvas>
</Boundary>
)}
</View>
</View>
<View style={styles.actions}>
<Button
testID="remount"
title={`Mount & unmount a canvas ${CHURN_CYCLES}Γ—`}
onPress={remount}
/>
<Button
testID="strict"
title={strict ? "StrictMode: on" : "StrictMode: off"}
onPress={() => setStrict((s) => !s)}
/>
<Button testID="lose" title="Lose context" onPress={lose} />
<Button testID="restore" title="Restore context" onPress={restore} />
<Button
testID="renderer"
title={isStatic ? "Renderer: static" : "Renderer: live"}
onPress={() => setIsStatic((s) => !s)}
/>
</View>
<Text style={styles.description}>
Mount & unmount: more cycles than the browser's context limit; the
reference canvas must not be evicted (Chrome logs "Too many active WebGL
contexts" when it is). StrictMode: re-runs the renderer on the same
element; the canvas must keep painting. Lose then restore: the canvas
goes blank, then resumes. Renderer: switches between the live and the
static renderer; both must paint.
</Text>
</ScrollView>
);
};

const styles = StyleSheet.create({
container: {
padding: 16,
gap: 12,
},
header: {
fontSize: 18,
fontWeight: "bold",
},
description: {
fontSize: 13,
color: "#666",
},
status: {
fontSize: 13,
fontFamily: Platform.select({ web: "monospace", default: undefined }),
},
row: {
flexDirection: "row",
gap: 12,
},
actions: {
gap: 8,
alignItems: "flex-start",
},
canvas: {
width: SIZE,
height: SIZE,
},
});
8 changes: 8 additions & 0 deletions apps/example/src/Examples/API/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { AtlasExample } from "./AtlasExample";
import { Web } from "./Web";
import { WebLayout } from "./WebLayout";
import { WebGLContexts } from "./WebGLContexts";
import { WebGLLifecycle } from "./WebGLLifecycle";
import { WebMemory } from "./WebMemory";

const Stack = createNativeStackNavigator<Routes>();
Expand Down Expand Up @@ -354,6 +355,13 @@ export const API = () => {
title: "πŸ”₯ WebGL Contexts",
}}
/>
<Stack.Screen
name="WebGLLifecycle"
component={WebGLLifecycle}
options={{
title: "♻️ WebGL Lifecycle",
}}
/>
<Stack.Screen
name="WebMemory"
component={WebMemory}
Expand Down
1 change: 1 addition & 0 deletions apps/example/src/Examples/API/linking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@ export const apiScreenPaths: Record<keyof Routes, string> = {
Web: "web",
WebLayout: "web-layout",
WebGLContexts: "webgl-contexts",
WebGLLifecycle: "webgl-lifecycle",
WebMemory: "web-memory",
};
Loading
Loading