From 09bf361dff18a9669b96b9d5a1abceba0578d058 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 1 Sep 2026 15:53:38 +0200 Subject: [PATCH 1/5] fix(charts): keep the skia canvas usable across an Activity hide WebGLRenderer.dispose() loses the canvas's WebGL context on every layout-effect cleanup, but an 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 #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. --- ...+defer-webgl-context-loss-to-unmount.patch | 52 +++++++++++++++++++ patches/@shopify/react-native-skia/details.md | 45 ++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch new file mode 100644 index 000000000000..6ac2a6426bde --- /dev/null +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch @@ -0,0 +1,52 @@ +diff --git a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js b/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js +index ec3b49e..a7ddd2b 100644 +--- a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js ++++ b/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js +@@ -100,8 +100,19 @@ class WebGLRenderer { + } + dispose() { + if (this.surface) { +- var _this$canvas; +- (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); ++ // A lost context is permanent for the element and effect cleanup is not unmount: an ++ // hide (or a StrictMode DEV re-run) disposes the renderer but keeps the ++ // , and the reveal re-creates a renderer on that same element, which can never get ++ // a usable context again once it was lost. Defer the release one microtask and only lose ++ // the context once React has really detached the element - at cleanup time it is still ++ // connected even during a real unmount (upstream issue #3976, PR #4027). A kept element ++ // also keeps its last frame while the hidden screen stays visible as a static backdrop. ++ const canvas = this.canvas; ++ queueMicrotask(() => { ++ if (canvas && !canvas.isConnected) { ++ canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); ++ } ++ }); + this.surface.ref.delete(); + this.surface = null; + } +diff --git a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js b/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js +index e1cd87b..4ea9833 100644 +--- a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js ++++ b/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js +@@ -93,8 +93,19 @@ class WebGLRenderer { + } + dispose() { + if (this.surface) { +- var _this$canvas; +- (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); ++ // A lost context is permanent for the element and effect cleanup is not unmount: an ++ // hide (or a StrictMode DEV re-run) disposes the renderer but keeps the ++ // , and the reveal re-creates a renderer on that same element, which can never get ++ // a usable context again once it was lost. Defer the release one microtask and only lose ++ // the context once React has really detached the element - at cleanup time it is still ++ // connected even during a real unmount (upstream issue #3976, PR #4027). A kept element ++ // also keeps its last frame while the hidden screen stays visible as a static backdrop. ++ const canvas = this.canvas; ++ queueMicrotask(() => { ++ if (canvas && !canvas.isConnected) { ++ canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); ++ } ++ }); + this.surface.ref.delete(); + this.surface = null; + } diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 5e02782df8f7..c4461845834c 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -97,3 +97,48 @@ - Upstream PR/issue: https://github.com/Shopify/react-native-skia/pull/3855 — the same fix, merged upstream on 2026-05-26. Drop this patch once the Skia dependency is bumped to >= 2.6.9. - E/App issue: https://github.com/Expensify/App/issues/98331, https://github.com/Expensify/App/issues/95905 - PR introducing patch: https://github.com/Expensify/App/pull/98437 + +### [@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch](@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch) + +- Reason: + + ``` + Fixes charts staying permanently blank on web whenever React re-runs the + layout effects of a mounted on the same DOM node - most importantly + a screen wrapped in React being hidden and revealed (the + ScreenActivityWrapper rollout), and StrictMode's DEV double-invoke. + + Hiding an Activity subtree runs effect cleanups: SkiaPictureView's cleanup + calls WebGLRenderer.dispose(), which called WEBGL_lose_context.loseContext() + on the . Per the WEBGL_lose_context spec that loss is permanent for + the element - a later getContext("webgl2") hands back the same lost context + and only restoreContext() can revive it. Revealing re-runs the effects and + creates a new renderer on the same canvas element, so + CanvasKit.MakeWebGLCanvasSurface can never succeed on it again - it throws + "Cannot read properties of null (reading 'rangeMin')" (which patch 002 turns + into the "unable to display chart" fallback). Losing the context also blanks + the canvas while the hidden screen can still be on screen: the app keeps a + hidden screen visible as a static backdrop (display: contents). + + Fix (mirrors upstream PR #4027): dispose() no longer loses the context + synchronously. It defers the release by one microtask and only calls + loseContext() when the canvas actually left the document - React runs effect + cleanup before detaching the host node, so the element is still connected at + cleanup time even during a real unmount, but one microtask later + isConnected is false only for a real unmount. An Activity hide keeps the + element connected, so the context - and the last presented frame the + backdrop shows - survives, and the reveal creates its new surface on the + live context exactly like the already-working resize path. A real unmount + still releases the context deterministically, which matters because + browsers cap a page at ~16 live WebGL contexts. + + A screen unmounted while hidden runs no cleanup at all, so its context is + reclaimed only by the browser's LRU force-loss once the cap is hit; a + leaked context is never used again, so it is the first eviction victim. + Context pressure from hidden screens matches the pre-Activity baseline, + where every mounted background screen held a live context anyway. + ``` + +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976 - the same root cause reported against upstream `main`, with two open fixes: https://github.com/Shopify/react-native-skia/pull/4027 (deferred `isConnected`-guarded release, which this patch mirrors) and https://github.com/Shopify/react-native-skia/pull/4002 (drops `loseContext()` entirely; safe upstream only because `deleteContext()` exists there, which 2.4.18 lacks). Revisit when either merges and the Skia dependency is bumped. +- E/App issue: https://github.com/Expensify/App/issues/98254 +- PR introducing patch: https://github.com/Expensify/App/pull/100714 From 9fbd2db159d28d49dc72c93a02da2d7a7ce09e35 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 9 Sep 2026 15:29:32 +0200 Subject: [PATCH 2/5] docs(patches): trim skia patch 004 and point it at the merged upstream fix Shorten the in-patch comment to three lines, drop the dead null guard on the canvas, and condense the details.md reason. Upstream #4027 was closed in favor of #4002, which merged on 2026-09-02 and is not in any release yet; record that, and that its canvas size reset would blank a hidden Activity backdrop, so a Skia bump does not fully replace this patch. --- ...+defer-webgl-context-loss-to-unmount.patch | 28 ++++----- patches/@shopify/react-native-skia/details.md | 60 ++++++++----------- 2 files changed, 36 insertions(+), 52 deletions(-) diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch index 6ac2a6426bde..b6084ebee32b 100644 --- a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch @@ -2,22 +2,18 @@ diff --git a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPict index ec3b49e..a7ddd2b 100644 --- a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js +++ b/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js -@@ -100,8 +100,19 @@ class WebGLRenderer { +@@ -100,8 +100,15 @@ class WebGLRenderer { } dispose() { if (this.surface) { - var _this$canvas; - (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); -+ // A lost context is permanent for the element and effect cleanup is not unmount: an -+ // hide (or a StrictMode DEV re-run) disposes the renderer but keeps the -+ // , and the reveal re-creates a renderer on that same element, which can never get -+ // a usable context again once it was lost. Defer the release one microtask and only lose -+ // the context once React has really detached the element - at cleanup time it is still -+ // connected even during a real unmount (upstream issue #3976, PR #4027). A kept element -+ // also keeps its last frame while the hidden screen stays visible as a static backdrop. ++ // A lost context is permanent for the element and effect cleanup is not unmount (an ++ // hide or a StrictMode re-run keeps the ), so only lose it once React ++ // has really detached the element (upstream Shopify/react-native-skia#4002). + const canvas = this.canvas; + queueMicrotask(() => { -+ if (canvas && !canvas.isConnected) { ++ if (!canvas.isConnected) { + canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); + } + }); @@ -28,22 +24,18 @@ diff --git a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictur index e1cd87b..4ea9833 100644 --- a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js +++ b/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js -@@ -93,8 +93,19 @@ class WebGLRenderer { +@@ -93,8 +93,15 @@ class WebGLRenderer { } dispose() { if (this.surface) { - var _this$canvas; - (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); -+ // A lost context is permanent for the element and effect cleanup is not unmount: an -+ // hide (or a StrictMode DEV re-run) disposes the renderer but keeps the -+ // , and the reveal re-creates a renderer on that same element, which can never get -+ // a usable context again once it was lost. Defer the release one microtask and only lose -+ // the context once React has really detached the element - at cleanup time it is still -+ // connected even during a real unmount (upstream issue #3976, PR #4027). A kept element -+ // also keeps its last frame while the hidden screen stays visible as a static backdrop. ++ // A lost context is permanent for the element and effect cleanup is not unmount (an ++ // hide or a StrictMode re-run keeps the ), so only lose it once React ++ // has really detached the element (upstream Shopify/react-native-skia#4002). + const canvas = this.canvas; + queueMicrotask(() => { -+ if (canvas && !canvas.isConnected) { ++ if (!canvas.isConnected) { + canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); + } + }); diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index c4461845834c..4aa852b4adbe 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -104,41 +104,33 @@ ``` Fixes charts staying permanently blank on web whenever React re-runs the - layout effects of a mounted on the same DOM node - most importantly - a screen wrapped in React being hidden and revealed (the - ScreenActivityWrapper rollout), and StrictMode's DEV double-invoke. - - Hiding an Activity subtree runs effect cleanups: SkiaPictureView's cleanup - calls WebGLRenderer.dispose(), which called WEBGL_lose_context.loseContext() - on the . Per the WEBGL_lose_context spec that loss is permanent for - the element - a later getContext("webgl2") hands back the same lost context - and only restoreContext() can revive it. Revealing re-runs the effects and - creates a new renderer on the same canvas element, so - CanvasKit.MakeWebGLCanvasSurface can never succeed on it again - it throws - "Cannot read properties of null (reading 'rangeMin')" (which patch 002 turns - into the "unable to display chart" fallback). Losing the context also blanks - the canvas while the hidden screen can still be on screen: the app keeps a - hidden screen visible as a static backdrop (display: contents). - - Fix (mirrors upstream PR #4027): dispose() no longer loses the context - synchronously. It defers the release by one microtask and only calls - loseContext() when the canvas actually left the document - React runs effect - cleanup before detaching the host node, so the element is still connected at - cleanup time even during a real unmount, but one microtask later - isConnected is false only for a real unmount. An Activity hide keeps the - element connected, so the context - and the last presented frame the - backdrop shows - survives, and the reveal creates its new surface on the - live context exactly like the already-working resize path. A real unmount - still releases the context deterministically, which matters because - browsers cap a page at ~16 live WebGL contexts. - - A screen unmounted while hidden runs no cleanup at all, so its context is - reclaimed only by the browser's LRU force-loss once the cap is hit; a - leaked context is never used again, so it is the first eviction victim. - Context pressure from hidden screens matches the pre-Activity baseline, - where every mounted background screen held a live context anyway. + layout effects of a mounted on the same DOM node: a screen wrapped + in React being hidden and revealed (the ScreenActivityWrapper + rollout) and StrictMode's DEV double-invoke. + + The effect cleanup calls WebGLRenderer.dispose(), which lost the WebGL + context of the . Per the WEBGL_lose_context spec that loss is + permanent for the element, so the renderer the re-run creates on the same + element can never get a surface again: CanvasKit.MakeWebGLCanvasSurface + throws "Cannot read properties of null (reading 'rangeMin')", which patch + 002 turns into the "unable to display chart" fallback. + + dispose() now defers the release by one microtask and only loses the + context when the canvas has left the document. React runs cleanup before + detaching the host node, so isConnected is false one microtask later only + for a real unmount, which keeps releasing the context deterministically + (browsers cap a page at ~16 live contexts). An Activity hide keeps the + element connected, so the context and the last presented frame survive + and the reveal builds its surface on the live context like the resize path + already does. The kept frame matters because ScreenActivityWrapper keeps + the hidden screen painted as a static backdrop (AlwaysPaintedView). + + A screen unmounted while hidden runs no cleanup, so its context is only + reclaimed by the browser's LRU force-loss once the cap is hit. That matches + the pre-Activity baseline, where every mounted background screen held a + live context anyway. ``` -- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976 - the same root cause reported against upstream `main`, with two open fixes: https://github.com/Shopify/react-native-skia/pull/4027 (deferred `isConnected`-guarded release, which this patch mirrors) and https://github.com/Shopify/react-native-skia/pull/4002 (drops `loseContext()` entirely; safe upstream only because `deleteContext()` exists there, which 2.4.18 lacks). Revisit when either merges and the Skia dependency is bumped. +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976, fixed by https://github.com/Shopify/react-native-skia/pull/4002 (merged 2026-09-02, not in any release as of 2026-09-09; the latest is 2.11.2). Its dispose() defers the loss the same way and adds context-restore handling, but it also zeroes the canvas size on cleanup, so a hidden Activity screen would show a blank chart in the backdrop. When the Skia dependency is bumped past that merge, either accept the blank backdrop and drop this patch or replace it with a patch that only removes the size reset. - E/App issue: https://github.com/Expensify/App/issues/98254 - PR introducing patch: https://github.com/Expensify/App/pull/100714 From 8205bbe33c0e32edded0c42af28769dbc2b5fda0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 9 Sep 2026 16:01:39 +0200 Subject: [PATCH 3/5] docs(patches): state the unmount-while-hidden context leak as a regression Before patch 004 every unmount released the WebGL context synchronously, background screens included, so the leak of a screen unmounted while hidden does not match the pre-Activity baseline. Say so instead of claiming parity. --- patches/@shopify/react-native-skia/details.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 4aa852b4adbe..3a0d124d6155 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -125,10 +125,13 @@ already does. The kept frame matters because ScreenActivityWrapper keeps the hidden screen painted as a static backdrop (AlwaysPaintedView). - A screen unmounted while hidden runs no cleanup, so its context is only - reclaimed by the browser's LRU force-loss once the cap is hit. That matches - the pre-Activity baseline, where every mounted background screen held a - live context anyway. + One case gets worse than before: a screen unmounted while hidden. Its + cleanup already ran at hide time, when the canvas was still connected, so + React runs none at unmount and the context is only reclaimed by the + browser's LRU force-loss once the cap is hit. Before the patch every + unmount, background screen or not, released its context synchronously. + Mounted screens are unchanged, each held a live context before Activity + too. Upstream #4002 has the same gap. ``` - Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976, fixed by https://github.com/Shopify/react-native-skia/pull/4002 (merged 2026-09-02, not in any release as of 2026-09-09; the latest is 2.11.2). Its dispose() defers the loss the same way and adds context-restore handling, but it also zeroes the canvas size on cleanup, so a hidden Activity screen would show a blank chart in the backdrop. When the Skia dependency is bumped past that merge, either accept the blank backdrop and drop this patch or replace it with a patch that only removes the size reset. From e85fcca0e45f9670f89308a6d0ee68b6a58ebc1d Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Thu, 10 Sep 2026 16:17:07 +0200 Subject: [PATCH 4/5] fix(patches): check isConnected synchronously in skia patch 004 In 2.4.18 dispose() runs from a passive useEffect cleanup, which React flushes after removing the host node, so the canvas is already detached on a real unmount and still connected across an Activity hide. The microtask only matters for a layout-effect caller like upstream's; drop it and say why in details.md. --- ...+defer-webgl-context-loss-to-unmount.patch | 26 +++++++------------ patches/@shopify/react-native-skia/details.md | 23 +++++++++------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch index b6084ebee32b..d719f711d5af 100644 --- a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.18+004+defer-webgl-context-loss-to-unmount.patch @@ -2,7 +2,7 @@ diff --git a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPict index ec3b49e..a7ddd2b 100644 --- a/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js +++ b/node_modules/@shopify/react-native-skia/lib/commonjs/views/SkiaPictureView.web.js -@@ -100,8 +100,15 @@ class WebGLRenderer { +@@ -100,8 +100,12 @@ class WebGLRenderer { } dispose() { if (this.surface) { @@ -10,13 +10,10 @@ index ec3b49e..a7ddd2b 100644 - (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); + // A lost context is permanent for the element and effect cleanup is not unmount (an + // hide or a StrictMode re-run keeps the ), so only lose it once React -+ // has really detached the element (upstream Shopify/react-native-skia#4002). -+ const canvas = this.canvas; -+ queueMicrotask(() => { -+ if (!canvas.isConnected) { -+ canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); -+ } -+ }); ++ // has detached the element (upstream Shopify/react-native-skia#4002). ++ if (!this.canvas.isConnected) { ++ this.canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); ++ } this.surface.ref.delete(); this.surface = null; } @@ -24,7 +21,7 @@ diff --git a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictur index e1cd87b..4ea9833 100644 --- a/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js +++ b/node_modules/@shopify/react-native-skia/lib/module/views/SkiaPictureView.web.js -@@ -93,8 +93,15 @@ class WebGLRenderer { +@@ -93,8 +93,12 @@ class WebGLRenderer { } dispose() { if (this.surface) { @@ -32,13 +29,10 @@ index e1cd87b..4ea9833 100644 - (_this$canvas = this.canvas) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getContext("webgl2")) === null || _this$canvas === void 0 || (_this$canvas = _this$canvas.getExtension("WEBGL_lose_context")) === null || _this$canvas === void 0 || _this$canvas.loseContext(); + // A lost context is permanent for the element and effect cleanup is not unmount (an + // hide or a StrictMode re-run keeps the ), so only lose it once React -+ // has really detached the element (upstream Shopify/react-native-skia#4002). -+ const canvas = this.canvas; -+ queueMicrotask(() => { -+ if (!canvas.isConnected) { -+ canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); -+ } -+ }); ++ // has detached the element (upstream Shopify/react-native-skia#4002). ++ if (!this.canvas.isConnected) { ++ this.canvas.getContext("webgl2")?.getExtension("WEBGL_lose_context")?.loseContext(); ++ } this.surface.ref.delete(); this.surface = null; } diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 3a0d124d6155..08b818b67dcf 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -115,15 +115,18 @@ throws "Cannot read properties of null (reading 'rangeMin')", which patch 002 turns into the "unable to display chart" fallback. - dispose() now defers the release by one microtask and only loses the - context when the canvas has left the document. React runs cleanup before - detaching the host node, so isConnected is false one microtask later only - for a real unmount, which keeps releasing the context deterministically - (browsers cap a page at ~16 live contexts). An Activity hide keeps the - element connected, so the context and the last presented frame survive - and the reveal builds its surface on the live context like the resize path - already does. The kept frame matters because ScreenActivityWrapper keeps - the hidden screen painted as a static backdrop (AlwaysPaintedView). + dispose() now only loses the context when the canvas has left the + document. In 2.4.18 it runs from a passive useEffect cleanup, which React + flushes after removing the host node, so isConnected is already false at + cleanup time on a real unmount, which keeps releasing the context + deterministically (browsers cap a page at ~16 live contexts). An Activity + hide keeps the element connected, so the context and the last presented + frame survive and the reveal builds its surface on the live context like + the resize path already does. The kept frame matters because + ScreenActivityWrapper keeps the hidden screen painted as a static backdrop + (AlwaysPaintedView). Upstream defers the same check by a microtask because + its dispose() runs from a layout effect cleanup, before the node is + detached; a passive caller needs no deferral. One case gets worse than before: a screen unmounted while hidden. Its cleanup already ran at hide time, when the canvas was still connected, so @@ -134,6 +137,6 @@ too. Upstream #4002 has the same gap. ``` -- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976, fixed by https://github.com/Shopify/react-native-skia/pull/4002 (merged 2026-09-02, not in any release as of 2026-09-09; the latest is 2.11.2). Its dispose() defers the loss the same way and adds context-restore handling, but it also zeroes the canvas size on cleanup, so a hidden Activity screen would show a blank chart in the backdrop. When the Skia dependency is bumped past that merge, either accept the blank backdrop and drop this patch or replace it with a patch that only removes the size reset. +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976, fixed by https://github.com/Shopify/react-native-skia/pull/4002 (merged 2026-09-02, not in any release as of 2026-09-09; the latest is 2.11.2). Its dispose() applies the same isConnected guard (deferred by a microtask, since its caller is a layout effect) and adds context-restore handling, but it also zeroes the canvas size on cleanup, so a hidden Activity screen would show a blank chart in the backdrop. When the Skia dependency is bumped past that merge, either accept the blank backdrop and drop this patch or replace it with a patch that only removes the size reset. - E/App issue: https://github.com/Expensify/App/issues/98254 - PR introducing patch: https://github.com/Expensify/App/pull/100714 From a1d710e59a7a92549018e196284b3814cfbbfa9c Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 11 Sep 2026 11:46:01 +0200 Subject: [PATCH 5/5] docs(patches): compare the unmount-while-hidden leak with upstream precisely Upstream #4002 skips the same eager context release, but its dispose() already frees the surface, the GrContext, the CanvasKit handle and the drawing buffer at every hide, so only the context slot survives there. Saying it has "the same gap" understated this patch's leak. --- patches/@shopify/react-native-skia/details.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 08b818b67dcf..547aa8c5b2b2 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -130,11 +130,15 @@ One case gets worse than before: a screen unmounted while hidden. Its cleanup already ran at hide time, when the canvas was still connected, so - React runs none at unmount and the context is only reclaimed by the - browser's LRU force-loss once the cap is hit. Before the patch every - unmount, background screen or not, released its context synchronously. - Mounted screens are unchanged, each held a live context before Activity - too. Upstream #4002 has the same gap. + React runs none at unmount and the context, with its full-size drawing + buffer, is only reclaimed by the browser's LRU force-loss once the cap is + hit. Before the patch every unmount, background screen or not, released + its context synchronously. Mounted screens are unchanged, each held a live + context before Activity too. Upstream #4002 skips the same release, but it + is less exposed: its dispose() frees the surface, the GrContext, the + CanvasKit context handle and the drawing buffer at every hide, so only the + context slot survives there. Freeing the buffer is the canvas size reset + that would blank the backdrop, which is why this patch does not copy it. ``` - Upstream PR/issue: https://github.com/Shopify/react-native-skia/issues/3976, fixed by https://github.com/Shopify/react-native-skia/pull/4002 (merged 2026-09-02, not in any release as of 2026-09-09; the latest is 2.11.2). Its dispose() applies the same isConnected guard (deferred by a microtask, since its caller is a layout effect) and adds context-restore handling, but it also zeroes the canvas size on cleanup, so a hidden Activity screen would show a blank chart in the backdrop. When the Skia dependency is bumped past that merge, either accept the blank backdrop and drop this patch or replace it with a patch that only removes the size reset.