Skip to content

fix(馃寪): don't lose the canvas WebGL context on layout-effect cleanup - #4041

Closed
wcandillon wants to merge 3 commits into
mainfrom
fix/web-canvas-layout-effect-rerun
Closed

wcandillon wants to merge 3 commits into
mainfrom
fix/web-canvas-layout-effect-rerun

Conversation

@wcandillon

Copy link
Copy Markdown
Contributor

Description

Fixes #3976. Builds on #4002 by @giaBaoJS, whose analysis and StrictMode test this PR carries forward (the original commit is kept as-is on this branch).

On web, a <Canvas> throws whenever React re-runs its layout effects on the same DOM node, because WebGLRenderer.dispose() calls loseContext() on the <canvas> element's WebGL context. Effect cleanup does not imply the host node is gone: StrictMode's DEV double-invoke and an Activity/offscreen reveal (how React Navigation / Expo Router keep inactive routes mounted) both re-run the layout effect against a preserved element. The re-created renderer then gets a non-zero handle from GetWebGLContext on the dead canvas and MakeWebGLContext faults inside wasm.

Change

#4002 removed the loseContext() call. This PR goes one step further and makes the lifetimes explicit: the renderer belongs to the layout effect, the WebGL context belongs to the <canvas> element, and WebGLRenderer reconciles the two.

  • Per-element context tracking. A WeakMap<HTMLCanvasElement, CanvasWebGL> records the element's context, its WEBGL_lose_context extension (captured while the context is healthy, since getExtension() returns null on a lost one), whether the loss has been announced, and the currently attached renderer.
  • Lost contexts are recovered, not crashed on. If a renderer is built on a canvas whose context is already lost, it asks the browser to restore it and finishes construction in webglcontextrestored, then requests a redraw. webglcontextlost is preventDefault()ed by element-lifetime listeners so the browser is allowed to restore an evicted context even while no renderer is attached (hidden Activity).
  • Contexts of detached elements are still released. dispose() queues a microtask that calls loseContext() only if no new renderer took the element and it has left the document. React runs the cleanup before removing the node, so this is the earliest point at which the two cases can be told apart. This matters because browsers cap live contexts per page (16 in Chrome) and evict the oldest one.
  • Drawing buffer freed on dispose. The canvas is sized to 0x0 so a canvas kept in the document without a renderer doesn't hold a full-size depth+stencil buffer. The next renderer's onResize() sizes it again.
  • draw, onResize, and makeImageSnapshot no-op on a lost context instead of touching freed GL objects.
  • MakeWebGLContext failure always unregisters the handle (try/finally), so a throw no longer leaves CanvasKit's registry retaining the canvas ([Web] Canvas unmount doesn't release WebGL context (retains whole DOM subtree); SkiaPictureView also leaks a new context on every relayout聽#3924).
  • The <canvas> gets a key per renderer kind. A canvas element is bound to one context kind for life, so switching between the static (2D) and live (WebGL) renderer needs a fresh element.

Tests

packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx grows from 4 to 10 tests. The CanvasKit mock models a per-element context that can be lost and restored, and GetWebGLContext returning a non-zero handle on a lost context (the reason the existing guard never fired). New cases:

  • survives its layout effect being re-run on the same canvas element (StrictMode)
  • loses the element's context once the element has left the document
  • frees the drawing buffer when its renderer is disposed
  • recovers from the browser losing and restoring the context
  • waits for a context lost while no renderer was attached
  • switches to a fresh canvas element when the renderer kind changes

馃 Generated with Claude Code

https://claude.ai/code/session_015BrCh6yHU4HvK1QSHoVWtk

giaBaoJS and others added 3 commits September 2, 2026 11:44
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 #3924 stays fixed.

Fixes #3976
@wcandillon wcandillon closed this Sep 2, 2026
@wcandillon wcandillon reopened this Sep 2, 2026
@wcandillon wcandillon closed this Sep 2, 2026
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