fix(🌐): keep the web canvas usable when its layout effect re-runs - #4027
Closed
dennytosp wants to merge 2 commits into
Closed
fix(🌐): keep the web canvas usable when its layout effect re-runs#4027dennytosp wants to merge 2 commits into
dennytosp wants to merge 2 commits into
Conversation
dennytosp
force-pushed
the
fix/keep-web-canvas-context-on-effect-rerun
branch
2 times, most recently
from
August 26, 2026 14:30
0101224 to
9f4c1c8
Compare
wcandillon
self-requested a review
August 30, 2026 15:50
wcandillon
reviewed
Aug 30, 2026
wcandillon
left a comment
Contributor
There was a problem hiding this comment.
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
force-pushed
the
fix/keep-web-canvas-context-on-effect-rerun
branch
from
August 31, 2026 14:23
9f4c1c8 to
d5cc427
Compare
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.
40 tasks
Contributor
|
Thanks a lot for this! I'm closing this in favor of #4002 hope it's ok |
Contributor
|
I'm re-opening this. I need to give it some thoughts. |
Contributor
|
I will publish a fix for it in #4002 |
40 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3976. Rebased onto
mainafter #4035.WebGLRenderer.dispose()callsWEBGL_lose_context.loseContext()on the<canvas>element. Per the spec that loses the element's context permanently — a latergetContext("webgl2")returns the same dead context object, and onlyrestoreContext()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:reappearLayoutEffectswhen a hidden Activity/offscreen subtree is revealed — which is how React Navigation and Expo Router keep inactive routes mounted.On the re-run
GetWebGLContextstill hands back a non-zero handle for the dead canvas, so theCould not create a WebGL contextguard never fires, andMakeWebGLContextthen faults inside wasm withCannot 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.isConnecteddistinguishes 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, loggingisConnectedfrom a layout-effect cleanup:truetrueroot.unmount()truefalsetruefalseSo 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: nonerather than removed, so it staysisConnectedand 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 throwawayOffscreenCanvasthat is never reused.Test. The new case in
SkiaPictureView.web.spec.tsxdrives the component throughStrictModeagainst a CanvasKit mock that models the failure the way the issue describes it: a lost context still yields a handle fromGetWebGLContext, andMakeWebGLContextis what throws. Onmainit fails with the reported error and stack —— 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
getContextstub, so the four pre-existing tests now exercise a real context object instead ofnull; they still pass unchanged, onmainas well as here.