Skip to content

fix(🌐): keep the web canvas usable when its layout effect re-runs - #4027

Closed
dennytosp wants to merge 2 commits into
Shopify:mainfrom
dennytosp:fix/keep-web-canvas-context-on-effect-rerun
Closed

fix(🌐): keep the web canvas usable when its layout effect re-runs#4027
dennytosp wants to merge 2 commits into
Shopify:mainfrom
dennytosp:fix/keep-web-canvas-context-on-effect-rerun

Conversation

@dennytosp

@dennytosp dennytosp commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #3976. Rebased onto main after #4035.

WebGLRenderer.dispose() calls WEBGL_lose_context.loseContext() on the <canvas> element. Per the spec that loses the element's context permanently — a later getContext("webgl2") returns the same dead context object, and only restoreContext() revives it.

But the renderer is created and disposed inside a useLayoutEffect, and effect cleanup does not mean the element is going away. React re-runs layout effects on a preserved host node in at least two everyday cases:

  • StrictMode's DEV double-invoke, on every mount in development;
  • reappearLayoutEffects when a hidden Activity/offscreen subtree is revealed — which is how React Navigation and Expo Router keep inactive routes mounted.

On the re-run GetWebGLContext still hands back a non-zero handle for the dead canvas, so the Could not create a WebGL context guard never fires, and MakeWebGLContext then faults inside wasm with Cannot read properties of null (reading 'rangeMin'), taking the tree down.

The fix keeps the release, it just waits until the element is actually gone. Dropping loseContext() outright would fix the crash, but it would also give up the deterministic GPU-context release this call was there for — browsers cap a page at ~16 live WebGL contexts (#3349, #3331, #3297, #2343, #956), and falling back to GC means a mount/unmount-heavy page can transiently exceed the cap, at which point the browser force-loses the oldest context and breaks a canvas that is still mounted.

canvas.isConnected distinguishes the two cases, but only one microtask after cleanup — at cleanup time React has not detached the node yet, so the check has to be deferred. Measured on React 19.0, logging isConnected from a layout-effect cleanup:

at cleanup one microtask later
StrictMode re-run true true
root.unmount() true false
conditional subtree unmount true false

So the guard is exact: on a re-run the canvas is still in the document and keeps its context, and on a real unmount the context is released a microtask later — no behaviour change for the case the call was added for. An Activity subtree is hidden with display: none rather than removed, so it stays isConnected and keeps its context, which is the point.

CanvasKit.deleteContext, which is what #3929 actually needed to stop unmounted views retaining their detached canvas elements, is untouched and still runs synchronously. StaticWebGLRenderer.cleanupRenderResult() also keeps its unconditional call: that one is on a throwaway OffscreenCanvas that is never reused.

Test. The new case in SkiaPictureView.web.spec.tsx drives the component through StrictMode against a CanvasKit mock that models the failure the way the issue describes it: a lost context still yields a handle from GetWebGLContext, and MakeWebGLContext is what throws. On main it fails with the reported error and stack —

TypeError: Cannot read properties of null (reading 'rangeMin')
  at new WebGLRenderer (src/views/SkiaPictureView.web.tsx:50:32)
  at reappearLayoutEffects
  at recursivelyTraverseReappearLayoutEffects
  at reappearLayoutEffects
  at doubleInvokeEffectsOnFiber
  at recursivelyTraverseAndDoubleInvokeEffectsInDEV

— and it asserts both directions: the context survives the re-run, and it is still lost after a real unmount. The mock is wired into the existing getContext stub, so the four pre-existing tests now exercise a real context object instead of null; they still pass unchanged, on main as well as here.

@dennytosp
dennytosp force-pushed the fix/keep-web-canvas-context-on-effect-rerun branch 2 times, most recently from 0101224 to 9f4c1c8 Compare August 26, 2026 14:30
@wcandillon
wcandillon self-requested a review August 30, 2026 15:50

@wcandillon wcandillon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree I think with the issue but I am not sure if I agree yet with the fix, is there a way maybe you could produce a small example in apps/example that would help reproduce the issue?

WebGLRenderer.dispose() called WEBGL_lose_context.loseContext() on the <canvas>
element, which per spec loses that element's context permanently - a later
getContext() returns the same dead context and only restoreContext() revives
it. But the renderer lives in a layout effect, and effect cleanup does not mean
the element is going away: StrictMode's DEV double-invoke and an Activity
reveal (how React Navigation keeps inactive routes mounted) both re-run the
effect on the very same node. The second construction then hands CanvasKit a
dead canvas and faults inside wasm with "Cannot read properties of null
(reading 'rangeMin')".

What Shopify#3929 needed was CanvasKit.deleteContext, which unregisters the context
and drops the reference to the detached element - that stays. loseContext only
released the drawing buffer eagerly, and it was the one part of dispose() that
left the element unusable.

The regression test drives the component through StrictMode with a WebGL
context mock that models the spec'd behaviour: once lost, the element's context
stays lost.

Fixes Shopify#3976
@dennytosp
dennytosp force-pushed the fix/keep-web-canvas-context-on-effect-rerun branch from 9f4c1c8 to d5cc427 Compare August 31, 2026 14:23
dariusz-biela added a commit to software-mansion-labs/expensify-app-fork that referenced this pull request Sep 1, 2026
WebGLRenderer.dispose() loses the canvas's WebGL context on every layout-effect
cleanup, but an <Activity> hide (and StrictMode's DEV double-invoke) re-runs the
effects on the same element, where a lost context is permanent - the reveal can
never create a surface again and the hidden backdrop shows a white block where
the chart was.

Patch skia to defer the release by one microtask and lose the context only once
the canvas has really left the document, mirroring upstream PR
Shopify/react-native-skia#4027 (issue Expensify#3976): a hidden screen keeps its context
and its last presented frame, the reveal reuses the live context like the resize
path always has, and a real unmount still releases the context slot
deterministically.
@wcandillon

Copy link
Copy Markdown
Contributor

Thanks a lot for this! I'm closing this in favor of #4002 hope it's ok

@wcandillon wcandillon closed this Sep 2, 2026
@wcandillon wcandillon reopened this Sep 2, 2026
@wcandillon

Copy link
Copy Markdown
Contributor

I'm re-opening this. I need to give it some thoughts.

@wcandillon

Copy link
Copy Markdown
Contributor

I will publish a fix for it in #4002

@wcandillon wcandillon closed this Sep 2, 2026
dariusz-biela added a commit to software-mansion-labs/expensify-app-fork that referenced this pull request Sep 9, 2026
WebGLRenderer.dispose() loses the canvas's WebGL context on every layout-effect
cleanup, but an <Activity> hide (and StrictMode's DEV double-invoke) re-runs the
effects on the same element, where a lost context is permanent - the reveal can
never create a surface again and the hidden backdrop shows a white block where
the chart was.

Patch skia to defer the release by one microtask and lose the context only once
the canvas has really left the document, mirroring upstream PR
Shopify/react-native-skia#4027 (issue Expensify#3976): a hidden screen keeps its context
and its last presented frame, the reveal reuses the live context like the resize
path always has, and a real unmount still releases the context slot
deterministically.
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