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
Steps to Reproduce
- Render any
<Canvas> on web inside <StrictMode> (React 19.2.3, react-native-web 0.21.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.
Description
On web, a
<Canvas>throwsCannot 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, becauseWebGLRenderer.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
Per the WEBGL_lose_context spec,
loseContext()puts that canvas element into a permanently lost state — a latergetContext("webgl2")returns the same lost context object, and onlyrestoreContext()can revive it. But the renderer is created and disposed in auseLayoutEffectwhose 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:doubleInvokeEffectsOnFiber/recursivelyTraverseAndDoubleInvokeEffectsInDEV) — every mount, in development.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 theCould not create a WebGL contextguard doesn't fire), andCanvasKit.MakeWebGLContext(handle)then faults inside wasm on a null pointer: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 inWebGLRenderer.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), andCanvasKit.deleteContext(this.contextHandle)— already indispose()— is what unregisters it.loseContext()only adds an eager release of the drawing buffer, and it is the sole part ofdispose()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 callsloseContext(), but that one is on a throwawayOffscreenCanvasthat 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
Steps to Reproduce
<Canvas>on web inside<StrictMode>(React 19.2.3, react-native-web 0.21.2).<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 triggersreappearLayoutEffects.