Skip to content

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

Description

@dccarmo

Description

On web, a <Canvas> throws Cannot read properties of null (reading 'rangeMin') (Firefox: can't access property "rangeMin", a is null) 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

this.canvas
  ?.getContext("webgl2")
  ?.getExtension("WEBGL_lose_context")
  ?.loseContext();

Per the WEBGL_lose_context spec, loseContext() puts that canvas element into a permanently lost state — a later getContext("webgl2") returns the same lost context object, and only restoreContext() can revive it. But the renderer is created and disposed in a useLayoutEffect whose cleanup does not imply the <canvas> element is gone. React re-runs layout effects on a preserved host node in at least two common cases:

  • StrictMode's DEV double-invoke (doubleInvokeEffectsOnFiber / recursivelyTraverseAndDoubleInvokeEffectsInDEV) — 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.

On the re-run, the constructor calls CanvasKit.GetWebGLContext(canvas) on the dead canvas. That returns a non-zero handle (so the Could not create a WebGL context guard doesn't fire), and CanvasKit.MakeWebGLContext(handle) then faults inside wasm on a null pointer:

at CanvasKitInit/</a.MakeWebGLContext
at WebGLRenderer
at SkiaPictureView/<
at commitHookEffectListMount
at commitHookLayoutEffects
at reappearLayoutEffects            <- Activity reveal
...
at doubleInvokeEffectsOnFiber       <- StrictMode DEV double-invoke
at recursivelyTraverseAndDoubleInvokeEffectsInDEV

This appears to be a regression from the fix for #3924 (released in 2.7.0) — both loseContext() calls arrived with that change. A single <Canvas> is enough to reproduce; it is not context exhaustion.

Expected: a <Canvas> survives its layout effect being re-run on the same element.

Actual: the second mount of the effect throws and takes down the tree.

Suggested fix

Dropping the loseContext() call in WebGLRenderer.dispose() restores working remounts while keeping #3924 fixed: the leak was caused by CanvasKit's internal GL registry retaining the context (and through it the detached DOM subtree), and CanvasKit.deleteContext(this.contextHandle) — already in dispose() — is what unregisters it. loseContext() only adds an eager release of the drawing buffer, and it is the sole part of dispose() that leaves the element unusable.

If the eager release is worth keeping, the alternative is to make the renderer's lifecycle own the element too — e.g. only lose the context when the <canvas> is actually being removed, or recreate/replace the <canvas> element when the renderer is recreated — rather than assuming effect-cleanup means unmount.

StaticWebGLRenderer.cleanupRenderResult() also calls loseContext(), but that one is on a throwaway OffscreenCanvas that is never reused, so it looks harmless.

We are running the one-line removal as a package patch and it resolves the crash for us.

React Native Skia Version

2.10.0

React Native Version

0.86.0

Using New Architecture

  • Enabled

Steps to Reproduce

  1. Render any <Canvas> on web inside <StrictMode> (React 19.2.3, react-native-web 0.21.2).
  2. Load the screen in development. The DEV double-invoke of layout effects disposes and recreates the renderer against the same <canvas> element, and the second construction throws.

The same crash occurs without StrictMode by navigating away from and back to a screen whose <Canvas> stays mounted but hidden (React Navigation / Expo Router), which triggers reappearLayoutEffects.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions