Skip to content

fix(🌐): don't lose the canvas WebGL context on layout-effect cleanup - #4002

Open
giaBaoJS wants to merge 2 commits into
Shopify:mainfrom
giaBaoJS:fix/web-canvas-layout-effect-rerun
Open

fix(🌐): don't lose the canvas WebGL context on layout-effect cleanup#4002
giaBaoJS wants to merge 2 commits into
Shopify:mainfrom
giaBaoJS:fix/web-canvas-layout-effect-rerun

Conversation

@giaBaoJS

Copy link
Copy Markdown
Contributor

Description

Fixes #3976.

On web, a <Canvas> throws whenever React re-runs its layout effects on the same DOM node, because WebGLRenderer.dispose() permanently destroys the <canvas> element's WebGL context:

https://github.com/Shopify/react-native-skia/blob/main/packages/skia/src/views/SkiaPictureView.web.tsx#L123-L126

dispose() is the cleanup of the useLayoutEffect that builds the renderer (SkiaPictureView.web.tsx:478-568, renderer.dispose() at :567), and effect cleanup does not imply the host node is gone. React re-runs layout effects against a preserved element in at least two routine cases:

  • StrictMode's DEV double-invoke — every mount, in development.
  • Activity / offscreen reveal (reappearLayoutEffects) — a hidden screen becoming visible again, which is how React Navigation / Expo Router keep inactive routes mounted.

Per the WEBGL_lose_context spec, loseContext() puts the context into a permanently lost state — a later getContext("webgl2") hands back the same lost object, and only restoreContext() can revive it (asynchronously, via webglcontextrestored, so it isn't usable from a synchronous constructor). On the re-run, the constructor gets a non-zero handle back from GetWebGLContext on the dead canvas — so the Could not create a WebGL context guard at :47 never fires — and MakeWebGLContext then fails at :50.

Change

One line removed: dispose() no longer calls loseContext() on the element it was handed. A comment is left in its place so it isn't reintroduced.

WebGLRenderer.dispose() has exactly one caller — the layout-effect cleanup at :567 — so this is the whole user-visible path.

This does not reintroduce the #3924 leak

The leak in #3924 was CanvasKit's internal Emscripten GL registry retaining the context object, and through it the <canvas> element and its whole detached subtree (the reporter's heap-snapshot retainer chain was Window → InternalNode(s) → <canvas> → parentNode → ...entire screen tree).

CanvasKit.deleteContext(this.contextHandle) — kept, and untouched by this PR — is what breaks that chain. From canvaskit-wasm's own implementation:

deleteContext = function (m) {
  I === ya[m] && (I = null);                              // clear current-context global
  "object" == typeof JSEvents && JSEvents.eg(ya[m].ce.canvas);  // remove listeners on the element
  ya[m]?.ce.canvas && (ya[m].ce.canvas.mf = void 0);      // clear the element's back-pointer
  ya[m] = null;                                           // drop the registry entry (and the element)
};

Every edge that retained the canvas in #3924 is severed there — including the +6,270 leaked addEventListener registrations the reporter measured, which JSEvents.eg(...) removes. loseContext() severs none of them: it is a WebGL-level operation that marks the context lost and releases its drawing buffer, leaving ya[m] and ya[m].ce.canvas fully intact. It never contributed to the retention chain, so removing it cannot restore it.

What is genuinely lost is the eager release of the canvas's drawing buffer on unmount. Two things bound that:

Alternatives considered

  • Lose the context only when the element is really going away (e.g. gate on canvas.isConnected). This does not work: React runs deletion effects — including layout-effect cleanups — before detaching the host node, so isConnected is still true during a real unmount. The flag can't distinguish the two cases, and would leave dead code plus false confidence.
  • Recreate the <canvas> element when the renderer is recreated. Changes the element identity behind canvasRef on every StrictMode mount; much larger blast radius for the same outcome.
  • Detect the lost context and call restoreContext(). Restoration is delivered asynchronously via webglcontextrestored, so it can't satisfy a synchronous constructor.

Test

Added to the existing packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx (4 tests → 5).

The CanvasKit mock in that file was extended to model the two behaviours this bug depends on, both matching the real implementations:

  • getContext("webgl2") returns one context object per element for the element's whole lifetime, and loseContext() marks that object permanently lost — so a later getContext hands the lost one back.
  • GetWebGLContext still returns a non-zero handle for a canvas whose context is lost (which is why the existing guard doesn't catch this), while MakeWebGLContext fails on it. In the browser that failure is a null-pointer fault inside wasm (Cannot read properties of null (reading 'rangeMin')); the mock returns null, which is the well-behaved model of the same failure and is already handled by the renderer. deleteContext unregisters the handle, so MakeWebGLContext on a deleted handle returns null as it does in CanvasKit.

The new test mounts the view inside <StrictMode> and asserts, non-vacuously, that GetWebGLContext was called more than once with a single distinct canvas element — i.e. the effect really was torn down and re-run against the same node — and that the surviving renderer still reaches drawPicture.

With the source change reverted it fails on exactly the frames from the report:

● SkiaPictureView.web › survives its layout effect being re-run on the same canvas element

  Could not create a graphics context

    at new WebGLRenderer (src/views/SkiaPictureView.web.tsx:54:13)
    at commitHookEffectListMount (react-dom-client.development.js:10758:29)
    at commitHookLayoutEffects (react-dom-client.development.js:10710:11)
    at reappearLayoutEffects (react-dom-client.development.js:12475:11)
    at recursivelyTraverseReappearLayoutEffects (react-dom-client.development.js:12589:9)
    at reappearLayoutEffects (react-dom-client.development.js:12574:11)
    at doubleInvokeEffectsOnFiber (react-dom-client.development.js:15694:11)
    at recursivelyTraverseAndDoubleInvokeEffectsInDEV (react-dom-client.development.js:15656:17)

Note that React's StrictMode double-invoke reaches the constructor through reappearLayoutEffects, so this one test covers both routes named in the issue.

Full suite in packages/skia, before and after: 746 → 747 passing, 0 failures (85 suites passed, 8 skipped; the extra test is the one added here). tsc --noEmit, eslint, and prettier --check are clean on both touched files.

Notes

giaBaoJS and others added 2 commits August 13, 2026 21:08
WebGLRenderer.dispose() runs from the cleanup of a layout effect, and
effect cleanup does not imply the <canvas> host node is gone: React
re-runs layout effects on a preserved element under StrictMode's DEV
double-invoke and on Activity/offscreen reveal.

WEBGL_lose_context.loseContext() is permanent for that element, so the
next renderer built on it got a non-zero handle back from
GetWebGLContext (the guard never fired) but could not build a
GrDirectContext on the dead context, and threw.

CanvasKit.deleteContext() is kept: it is what unregisters the context
from the Emscripten GL registry and drops the reference to the canvas
element, so the leak fixed in Shopify#3924 stays fixed.

Fixes Shopify#3976
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Web] Canvas crashes on layout-effect re-run: dispose() loses the reused canvas element's WebGL context (MakeWebGLContext null, 'rangeMin')

2 participants