fix(🌐): don't lose the canvas WebGL context on layout-effect cleanup - #4002
Open
giaBaoJS wants to merge 2 commits into
Open
fix(🌐): don't lose the canvas WebGL context on layout-effect cleanup#4002giaBaoJS wants to merge 2 commits into
giaBaoJS wants to merge 2 commits into
Conversation
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 Shopify#3924 stays fixed. Fixes Shopify#3976
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.
Description
Fixes #3976.
On web, a
<Canvas>throws 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
dispose()is the cleanup of theuseLayoutEffectthat builds the renderer (SkiaPictureView.web.tsx:478-568,renderer.dispose()at:567), and effect cleanup does not imply the host node is gone. React re-runs layout effects against a preserved element in at least two routine cases:reappearLayoutEffects) — a hidden screen becoming visible again, which is how React Navigation / Expo Router keep inactive routes mounted.Per the
WEBGL_lose_contextspec,loseContext()puts the context into a permanently lost state — a latergetContext("webgl2")hands back the same lost object, and onlyrestoreContext()can revive it (asynchronously, viawebglcontextrestored, so it isn't usable from a synchronous constructor). On the re-run, the constructor gets a non-zero handle back fromGetWebGLContexton the dead canvas — so theCould not create a WebGL contextguard at:47never fires — andMakeWebGLContextthen fails at:50.Change
One line removed:
dispose()no longer callsloseContext()on the element it was handed. A comment is left in its place so it isn't reintroduced.WebGLRenderer.dispose()has exactly one caller — the layout-effect cleanup at:567— so this is the whole user-visible path.This does not reintroduce the #3924 leak
The leak in #3924 was CanvasKit's internal Emscripten GL registry retaining the context object, and through it the
<canvas>element and its whole detached subtree (the reporter's heap-snapshot retainer chain wasWindow → InternalNode(s) → <canvas> → parentNode → ...entire screen tree).CanvasKit.deleteContext(this.contextHandle)— kept, and untouched by this PR — is what breaks that chain. Fromcanvaskit-wasm's own implementation:Every edge that retained the canvas in #3924 is severed there — including the
+6,270 leaked addEventListener registrationsthe reporter measured, whichJSEvents.eg(...)removes.loseContext()severs none of them: it is a WebGL-level operation that marks the context lost and releases its drawing buffer, leavingya[m]andya[m].ce.canvasfully intact. It never contributed to the retention chain, so removing it cannot restore it.What is genuinely lost is the eager release of the canvas's drawing buffer on unmount. Two things bound that:
grContext.releaseResourcesAndAbandonContext()at:119still runs, so all of Skia's GPU resources — the large majority of the GPU memory — are released eagerly regardless.deleteContext()unregistering it, the canvas element genuinely becomes collectable, so the browser reclaims the context and its backing store at the next GC. That is bounded, not a leak; the unbounded retention [Web] Canvas unmount doesn't release WebGL context (retains whole DOM subtree); SkiaPictureView also leaks a new context on every relayout #3924 reported was precisely because collection could never happen.Alternatives considered
canvas.isConnected). This does not work: React runs deletion effects — including layout-effect cleanups — before detaching the host node, soisConnectedis stilltrueduring a real unmount. The flag can't distinguish the two cases, and would leave dead code plus false confidence.<canvas>element when the renderer is recreated. Changes the element identity behindcanvasRefon every StrictMode mount; much larger blast radius for the same outcome.restoreContext(). Restoration is delivered asynchronously viawebglcontextrestored, so it can't satisfy a synchronous constructor.Test
Added to the existing
packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx(4 tests → 5).The CanvasKit mock in that file was extended to model the two behaviours this bug depends on, both matching the real implementations:
getContext("webgl2")returns one context object per element for the element's whole lifetime, andloseContext()marks that object permanently lost — so a latergetContexthands the lost one back.GetWebGLContextstill returns a non-zero handle for a canvas whose context is lost (which is why the existing guard doesn't catch this), whileMakeWebGLContextfails on it. In the browser that failure is a null-pointer fault inside wasm (Cannot read properties of null (reading 'rangeMin')); the mock returnsnull, which is the well-behaved model of the same failure and is already handled by the renderer.deleteContextunregisters the handle, soMakeWebGLContexton a deleted handle returnsnullas it does in CanvasKit.The new test mounts the view inside
<StrictMode>and asserts, non-vacuously, thatGetWebGLContextwas called more than once with a single distinct canvas element — i.e. the effect really was torn down and re-run against the same node — and that the surviving renderer still reachesdrawPicture.With the source change reverted it fails on exactly the frames from the report:
Note that React's StrictMode double-invoke reaches the constructor through
reappearLayoutEffects, so this one test covers both routes named in the issue.Full suite in
packages/skia, before and after: 746 → 747 passing, 0 failures (85 suites passed, 8 skipped; the extra test is the one added here).tsc --noEmit,eslint, andprettier --checkare clean on both touched files.Notes
StaticWebGLRenderer.cleanupRenderResult()(:230-233) also callsloseContext(), but on a throwawayOffscreenCanvascreated fresh per draw (:164) and never reused — there the eager release is correct, so it is deliberately left alone.onResizethrow sites, notdispose()); the two merge independently in either order. They're also complementary rather than overlapping: fix(🐛): don't crash the web view when no WebGL surface can be created #3996 would turn this crash into a silently blank canvas, since the renderer would go inert every time the effect re-ran. This PR removes the cause.