From 493a88a6433db2480a20e8a763c1e6efd0f43fb1 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 11:00:31 +0200 Subject: [PATCH 1/2] chore: Upgrade react-native-skia to 2.11.0 --- package-lock.json | 54 +++++- package.json | 2 +- ...1.0+001+fix-runtime-aware-cache-uaf.patch} | 37 ++-- ...1.0+002+fallback-to-software-surface.patch | 174 ++++++++++++++++++ ....14+002+fallback-to-software-surface.patch | 102 ---------- ...a+2.4.14+003+fix-dispose-symbol-eval.patch | 18 -- patches/@shopify/react-native-skia/details.md | 80 +++----- 7 files changed, 264 insertions(+), 203 deletions(-) rename patches/@shopify/react-native-skia/{@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch => @shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch} (75%) create mode 100644 patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch delete mode 100644 patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch delete mode 100644 patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch diff --git a/package-lock.json b/package-lock.json index d4cc09120e9a..b1633bd981ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", "@shopify/flash-list": "2.3.0", - "@shopify/react-native-skia": "^2.4.14", + "@shopify/react-native-skia": "^2.11.0", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", @@ -16722,13 +16722,16 @@ } }, "node_modules/@shopify/react-native-skia": { - "version": "2.4.14", - "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz", - "integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==", - "hasInstallScript": true, + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.11.0.tgz", + "integrity": "sha512-XZJoJMhCtdxpe/Ke7Bx3RqXZtdR9DUdECYkQEFFYrCmosTc6VsjSNZYXJdS1MScU7QNbci6t09L3y02jUoMLvw==", "license": "MIT", "dependencies": { - "canvaskit-wasm": "0.40.0", + "canvaskit-wasm": "0.41.0", + "react-native-skia-android": "152.0.0", + "react-native-skia-apple-ios": "152.0.0", + "react-native-skia-apple-macos": "152.0.0", + "react-native-skia-apple-tvos": "152.0.0", "react-reconciler": "0.31.0" }, "bin": { @@ -16737,7 +16740,8 @@ "peerDependencies": { "react": ">=19.0", "react-native": ">=0.78", - "react-native-reanimated": ">=3.19.1" + "react-native-reanimated": ">=4.0.0", + "react-native-worklets": ">=0.7.0" }, "peerDependenciesMeta": { "react-native": { @@ -16745,9 +16749,21 @@ }, "react-native-reanimated": { "optional": true + }, + "react-native-worklets": { + "optional": true } } }, + "node_modules/@shopify/react-native-skia/node_modules/canvaskit-wasm": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz", + "integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "devOptional": true, @@ -36544,6 +36560,30 @@ "node": ">=16" } }, + "node_modules/react-native-skia-android": { + "version": "152.0.0", + "resolved": "https://registry.npmjs.org/react-native-skia-android/-/react-native-skia-android-152.0.0.tgz", + "integrity": "sha512-X++QG8bpPLHpavUyNfQvAxvtjH3r8fnivxS1PTFZsKL4ZiAJ1V9IVsJi4qltafWJbzhq7IutpZQNYd06uwNkjg==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-ios": { + "version": "152.0.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-ios/-/react-native-skia-apple-ios-152.0.0.tgz", + "integrity": "sha512-rmnHYWkwvxm7B9fXZCkTRdZVoQwVnNydjytfphxatweQmEqwYE+/g1LOL/emhM/ulmwAFKFT0VNAEzAlvHId2A==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-macos": { + "version": "152.0.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-macos/-/react-native-skia-apple-macos-152.0.0.tgz", + "integrity": "sha512-boWCDyPPJUuKwcE+DU8X7dop6EwLdrOH+v7pxYAj+43NbOwgfV1wzq4g+kWIQqag5ZOQInq2mVwCc8S1eJjlXw==", + "license": "MIT" + }, + "node_modules/react-native-skia-apple-tvos": { + "version": "152.0.0", + "resolved": "https://registry.npmjs.org/react-native-skia-apple-tvos/-/react-native-skia-apple-tvos-152.0.0.tgz", + "integrity": "sha512-+UnXke4Yz9Jmy3uTcDbYFrVG/+CZMqYbaTV3f52wovaMzf8wWy+iZampZr6dW6rOWLWrsUFiq7yMImg6UGbiFg==", + "license": "MIT" + }, "node_modules/react-native-svg": { "version": "15.15.5", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", diff --git a/package.json b/package.json index 5a50d9ba59c0..14fe5db12d9a 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "@sentry/core": "10.47.0", "@sentry/react-native": "8.7.0", "@shopify/flash-list": "2.3.0", - "@shopify/react-native-skia": "^2.4.14", + "@shopify/react-native-skia": "^2.11.0", "@ua/react-native-airship": "26.5.0", "array.prototype.tosorted": "^1.1.4", "awesome-phonenumber": "^5.4.0", diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch similarity index 75% rename from patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch rename to patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch index 5ca5160f88ff..10d9fb4acf0d 100644 --- a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch @@ -1,16 +1,8 @@ diff --git a/node_modules/@shopify/react-native-skia/cpp/jsi/RuntimeAwareCache.h b/node_modules/@shopify/react-native-skia/cpp/jsi/RuntimeAwareCache.h -index 2b5f16b..f716161 100644 +index 18d68d3..e7d9bde 100644 --- a/node_modules/@shopify/react-native-skia/cpp/jsi/RuntimeAwareCache.h +++ b/node_modules/@shopify/react-native-skia/cpp/jsi/RuntimeAwareCache.h -@@ -3,6 +3,7 @@ - #include - - #include -+#include - #include - #include - -@@ -56,17 +57,37 @@ class RuntimeAwareCache : public BaseRuntimeAwareCache, +@@ -60,7 +60,18 @@ class RuntimeAwareCache : public BaseRuntimeAwareCache, public: void onRuntimeDestroyed(jsi::Runtime *rt) override { @@ -18,16 +10,19 @@ index 2b5f16b..f716161 100644 + if (getMainJsRuntime() == rt) { + // Release the primary cache here, while the runtime is still valid, instead + // of in ~RuntimeAwareCache which may run later (main queue) after the -+ // runtime is freed -> UAF. See patches/@shopify/react-native-skia. ++ // runtime is freed -> UAF. Reset by move-assigning a fresh T: this is the ++ // same point in the runtime's lifecycle at which the secondary branch below ++ // erases its entries. See patches/@shopify/react-native-skia. + std::lock_guard lock(_primaryCacheMutex); + if (!_primaryCacheHandled) { -+ _primaryCache.clear(); ++ _primaryCache = T{}; + _primaryCacheHandled = true; + } + } else { - // We are removing a secondary runtime - _secondaryRuntimeCaches.erase(rt); - } + // We are removing a secondary runtime. This is invoked by + // RuntimeLifecycleMonitor on the destroyed runtime's thread, which may + // run concurrently with get() on another runtime's thread. +@@ -70,6 +81,17 @@ public: } ~RuntimeAwareCache() { @@ -45,11 +40,7 @@ index 2b5f16b..f716161 100644 for (auto &cache : _secondaryRuntimeCaches) { RuntimeLifecycleMonitor::removeListener( *static_cast(cache.first), this); - } - } - - T &get(jsi::Runtime &rt) { -@@ -74,6 +93,13 @@ public: +@@ -81,6 +103,13 @@ public: // to avoid us having to lookup by runtime for caches that only has a single // runtime if (getMainJsRuntime() == &rt) { @@ -62,11 +53,11 @@ index 2b5f16b..f716161 100644 + } return _primaryCache; } else { - if (_secondaryRuntimeCaches.count(&rt) == 0) { -@@ -94,6 +120,10 @@ public: - } + // Guard the secondary map: it can be mutated concurrently by get() +@@ -110,6 +139,10 @@ public: private: + std::mutex _secondaryCachesMutex; + // Declared before _primaryCache so it outlives it during member destruction. + std::mutex _primaryCacheMutex; + jsi::Runtime *_primaryRuntime = nullptr; diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch new file mode 100644 index 000000000000..eec8f096521f --- /dev/null +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch @@ -0,0 +1,174 @@ +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 407ab37..cbd08b2 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 +@@ -30,19 +30,29 @@ class WebGLRenderer { + _defineProperty(this, "grContext", null); + _defineProperty(this, "contextHandle", 0); + _defineProperty(this, "pd", 1); ++ _defineProperty(this, "isSoftware", false); ++ // Context creation fails when the browser cannot give us WebGL2 - hardware acceleration disabled, ++ // GPU blocklisted, or the per-page live context limit exhausted. Throwing here tears down the view ++ // tree, and the sibling throw in onResize escapes a ResizeObserver callback where no try/catch or ++ // React error boundary can reach it. Fall back to CanvasKit's software surface instead, matching how ++ // renderPictureToSurface below already treats a failed WebGL surface as recoverable. + this.contextHandle = CanvasKit.GetWebGLContext(canvas); +- if (!this.contextHandle) { +- throw new Error("Could not create a WebGL context"); +- } +- this.grContext = CanvasKit.MakeWebGLContext(this.contextHandle); +- if (!this.grContext) { +- CanvasKit.deleteContext(this.contextHandle); +- this.contextHandle = 0; +- throw new Error("Could not create a graphics context"); ++ if (this.contextHandle) { ++ this.grContext = CanvasKit.MakeWebGLContext(this.contextHandle); + } +- const ctx = canvas.getContext("webgl2"); +- if (ctx) { +- ctx.drawingBufferColorSpace = "display-p3"; ++ if (this.grContext) { ++ const ctx = canvas.getContext("webgl2"); ++ if (ctx) { ++ ctx.drawingBufferColorSpace = "display-p3"; ++ } ++ } else { ++ if (this.contextHandle) { ++ CanvasKit.deleteContext(this.contextHandle); ++ this.contextHandle = 0; ++ } ++ // Let onResize try the software surface. It can only succeed when GetWebGLContext failed, since a ++ // canvas that already holds a WebGL context can never hand back a 2D one. ++ this.isSoftware = true; + } + this.onResize(); + } +@@ -61,7 +71,7 @@ class WebGLRenderer { + const { + canvas + } = this; +- if (!this.grContext) { ++ if (!this.grContext && !this.isSoftware) { + return; + } + this.pd = window.devicePixelRatio; +@@ -76,9 +86,30 @@ class WebGLRenderer { + } + // Reuse the existing WebGL context and GrDirectContext: only the surface + // needs to be recreated when the canvas is resized. +- const surface = CanvasKit.MakeOnScreenGLSurface(this.grContext, canvas.width, canvas.height, CanvasKit.ColorSpace.SRGB); ++ let surface = this.grContext ? CanvasKit.MakeOnScreenGLSurface(this.grContext, canvas.width, canvas.height, CanvasKit.ColorSpace.SRGB) : null; + if (!surface) { +- throw new Error("Could not create surface"); ++ try { ++ // MakeSWCanvasSurface only stores the canvas and calls getContext("2d") later, when the surface ++ // is flushed. A canvas that already holds a WebGL context can never return a 2D context, so ++ // creating the surface anyway would defer the failure into an uncatchable putImageData error. ++ surface = canvas.getContext("2d") ? CanvasKit.MakeSWCanvasSurface(canvas) : null; ++ } catch (e) { ++ surface = null; ++ } ++ this.isSoftware = surface !== null; ++ } ++ if (!surface) { ++ this.surface = null; ++ // Let the host application know the chart cannot render, so it can show a fallback instead of a ++ // blank canvas. Without a listener this is a no-op. Deferred a frame so a listener attached in a ++ // React effect is registered before the event fires - the renderer is built in a layout effect, ++ // which runs first. ++ requestAnimationFrame(() => { ++ canvas.dispatchEvent(new CustomEvent("skia-surface-unavailable", { ++ bubbles: true ++ })); ++ }); ++ return; + } + this.surface = new _JsiSkSurface.JsiSkSurface(CanvasKit, surface); + } +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 7a9ea19..c24ab14 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 +@@ -24,19 +24,29 @@ class WebGLRenderer { + _defineProperty(this, "grContext", null); + _defineProperty(this, "contextHandle", 0); + _defineProperty(this, "pd", 1); ++ _defineProperty(this, "isSoftware", false); ++ // Context creation fails when the browser cannot give us WebGL2 - hardware acceleration disabled, ++ // GPU blocklisted, or the per-page live context limit exhausted. Throwing here tears down the view ++ // tree, and the sibling throw in onResize escapes a ResizeObserver callback where no try/catch or ++ // React error boundary can reach it. Fall back to CanvasKit's software surface instead, matching how ++ // renderPictureToSurface below already treats a failed WebGL surface as recoverable. + this.contextHandle = CanvasKit.GetWebGLContext(canvas); +- if (!this.contextHandle) { +- throw new Error("Could not create a WebGL context"); +- } +- this.grContext = CanvasKit.MakeWebGLContext(this.contextHandle); +- if (!this.grContext) { +- CanvasKit.deleteContext(this.contextHandle); +- this.contextHandle = 0; +- throw new Error("Could not create a graphics context"); ++ if (this.contextHandle) { ++ this.grContext = CanvasKit.MakeWebGLContext(this.contextHandle); + } +- const ctx = canvas.getContext("webgl2"); +- if (ctx) { +- ctx.drawingBufferColorSpace = "display-p3"; ++ if (this.grContext) { ++ const ctx = canvas.getContext("webgl2"); ++ if (ctx) { ++ ctx.drawingBufferColorSpace = "display-p3"; ++ } ++ } else { ++ if (this.contextHandle) { ++ CanvasKit.deleteContext(this.contextHandle); ++ this.contextHandle = 0; ++ } ++ // Let onResize try the software surface. It can only succeed when GetWebGLContext failed, since a ++ // canvas that already holds a WebGL context can never hand back a 2D one. ++ this.isSoftware = true; + } + this.onResize(); + } +@@ -55,7 +65,7 @@ class WebGLRenderer { + const { + canvas + } = this; +- if (!this.grContext) { ++ if (!this.grContext && !this.isSoftware) { + return; + } + this.pd = window.devicePixelRatio; +@@ -70,9 +80,30 @@ class WebGLRenderer { + } + // Reuse the existing WebGL context and GrDirectContext: only the surface + // needs to be recreated when the canvas is resized. +- const surface = CanvasKit.MakeOnScreenGLSurface(this.grContext, canvas.width, canvas.height, CanvasKit.ColorSpace.SRGB); ++ let surface = this.grContext ? CanvasKit.MakeOnScreenGLSurface(this.grContext, canvas.width, canvas.height, CanvasKit.ColorSpace.SRGB) : null; + if (!surface) { +- throw new Error("Could not create surface"); ++ try { ++ // MakeSWCanvasSurface only stores the canvas and calls getContext("2d") later, when the surface ++ // is flushed. A canvas that already holds a WebGL context can never return a 2D context, so ++ // creating the surface anyway would defer the failure into an uncatchable putImageData error. ++ surface = canvas.getContext("2d") ? CanvasKit.MakeSWCanvasSurface(canvas) : null; ++ } catch (e) { ++ surface = null; ++ } ++ this.isSoftware = surface !== null; ++ } ++ if (!surface) { ++ this.surface = null; ++ // Let the host application know the chart cannot render, so it can show a fallback instead of a ++ // blank canvas. Without a listener this is a no-op. Deferred a frame so a listener attached in a ++ // React effect is registered before the event fires - the renderer is built in a layout effect, ++ // which runs first. ++ requestAnimationFrame(() => { ++ canvas.dispatchEvent(new CustomEvent("skia-surface-unavailable", { ++ bubbles: true ++ })); ++ }); ++ return; + } + this.surface = new JsiSkSurface(CanvasKit, surface); + } diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch deleted file mode 100644 index aea542f13e86..000000000000 --- a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch +++ /dev/null @@ -1,102 +0,0 @@ -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 86c767e..ec3b49e 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 -@@ -49,13 +49,41 @@ class WebGLRenderer { - } = this; - canvas.width = canvas.clientWidth * pd; - canvas.height = canvas.clientHeight * pd; -- const surface = CanvasKit.MakeWebGLCanvasSurface(canvas); -- const ctx = canvas.getContext("webgl2"); -- if (ctx) { -- ctx.drawingBufferColorSpace = "display-p3"; -+ // MakeWebGLCanvasSurface throws when no WebGL2 context can be created (GPU unavailable, blocklisted, -+ // or the per-page context limit is exhausted). onResize runs from a ResizeObserver callback, so that -+ // throw escapes as an unhandled error. Fall back to CanvasKit's software surface, matching how -+ // renderPictureToSurface below already treats a failed WebGL surface as recoverable. -+ let surface = null; -+ try { -+ surface = CanvasKit.MakeWebGLCanvasSurface(canvas); -+ const ctx = canvas.getContext("webgl2"); -+ if (ctx) { -+ ctx.drawingBufferColorSpace = "display-p3"; -+ } -+ } catch (e) { -+ surface = null; - } - if (!surface) { -- throw new Error("Could not create surface"); -+ try { -+ // MakeSWCanvasSurface only stores the canvas and calls getContext("2d") later, when the surface -+ // is flushed. A canvas that already holds a WebGL context can never return a 2D context, so -+ // creating the surface anyway would defer the failure into an uncatchable putImageData error. -+ surface = canvas.getContext("2d") ? CanvasKit.MakeSWCanvasSurface(canvas) : null; -+ } catch (e) { -+ surface = null; -+ } -+ } -+ if (!surface) { -+ this.surface = null; -+ // Let the host application know the chart cannot render, so it can show a fallback instead of a -+ // blank canvas. Without a listener this is a no-op. Deferred a frame so a listener attached in a -+ // React effect during the same commit that mounted this view is registered before the event fires. -+ requestAnimationFrame(() => { -+ canvas.dispatchEvent(new CustomEvent("skia-surface-unavailable", { -+ bubbles: true -+ })); -+ }); -+ return; - } - this.surface = new _JsiSkSurface.JsiSkSurface(CanvasKit, surface); - } -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 2563277..e1cd87b 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 -@@ -42,13 +42,41 @@ class WebGLRenderer { - } = this; - canvas.width = canvas.clientWidth * pd; - canvas.height = canvas.clientHeight * pd; -- const surface = CanvasKit.MakeWebGLCanvasSurface(canvas); -- const ctx = canvas.getContext("webgl2"); -- if (ctx) { -- ctx.drawingBufferColorSpace = "display-p3"; -+ // MakeWebGLCanvasSurface throws when no WebGL2 context can be created (GPU unavailable, blocklisted, -+ // or the per-page context limit is exhausted). onResize runs from a ResizeObserver callback, so that -+ // throw escapes as an unhandled error. Fall back to CanvasKit's software surface, matching how -+ // renderPictureToSurface below already treats a failed WebGL surface as recoverable. -+ let surface = null; -+ try { -+ surface = CanvasKit.MakeWebGLCanvasSurface(canvas); -+ const ctx = canvas.getContext("webgl2"); -+ if (ctx) { -+ ctx.drawingBufferColorSpace = "display-p3"; -+ } -+ } catch (e) { -+ surface = null; - } - if (!surface) { -- throw new Error("Could not create surface"); -+ try { -+ // MakeSWCanvasSurface only stores the canvas and calls getContext("2d") later, when the surface -+ // is flushed. A canvas that already holds a WebGL context can never return a 2D context, so -+ // creating the surface anyway would defer the failure into an uncatchable putImageData error. -+ surface = canvas.getContext("2d") ? CanvasKit.MakeSWCanvasSurface(canvas) : null; -+ } catch (e) { -+ surface = null; -+ } -+ } -+ if (!surface) { -+ this.surface = null; -+ // Let the host application know the chart cannot render, so it can show a fallback instead of a -+ // blank canvas. Without a listener this is a no-op. Deferred a frame so a listener attached in a -+ // React effect during the same commit that mounted this view is registered before the event fires. -+ requestAnimationFrame(() => { -+ canvas.dispatchEvent(new CustomEvent("skia-surface-unavailable", { -+ bubbles: true -+ })); -+ }); -+ return; - } - this.surface = new JsiSkSurface(CanvasKit, surface); - } diff --git a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch deleted file mode 100644 index aca8d05aaef6..000000000000 --- a/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch +++ /dev/null @@ -1,18 +0,0 @@ -diff --git a/node_modules/@shopify/react-native-skia/cpp/jsi/JsiHostObject.cpp b/node_modules/@shopify/react-native-skia/cpp/jsi/JsiHostObject.cpp -index ef1654d..b41fbfd 100644 ---- a/node_modules/@shopify/react-native-skia/cpp/jsi/JsiHostObject.cpp -+++ b/node_modules/@shopify/react-native-skia/cpp/jsi/JsiHostObject.cpp -@@ -85,8 +85,11 @@ jsi::Value JsiHostObject::get(jsi::Runtime &runtime, - - // Check for dispose symbol as last resort - static const auto disposeSymbol = jsi::PropNameID::forSymbol( -- runtime, -- eval(runtime, "Symbol.for('Symbol.dispose');").getSymbol(runtime)); -+ runtime, runtime.global() -+ .getPropertyAsObject(runtime, "Symbol") -+ .getPropertyAsFunction(runtime, "for") -+ .call(runtime, "Symbol.dispose") -+ .getSymbol(runtime)); - if (jsi::PropNameID::compare(runtime, disposeSymbol, name)) { - // Recursively call get with "dispose" string - auto disposeName = jsi::PropNameID::forAscii(runtime, "dispose"); diff --git a/patches/@shopify/react-native-skia/details.md b/patches/@shopify/react-native-skia/details.md index 515a8377ec20..a3bcfc718c56 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -1,6 +1,6 @@ # `@shopify/react-native-skia` patches -### [@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch](@shopify+react-native-skia+2.4.14+001+fix-runtime-aware-cache-uaf.patch) +### [@shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch](@shopify+react-native-skia+2.11.0+001+fix-runtime-aware-cache-uaf.patch) - Reason: @@ -9,13 +9,13 @@ switching from NewDot to Expensify Classic on iOS HybridApp. RuntimeAwareCache only tracks the lifecycle of secondary runtimes; for the primary runtime it assumes the cache owner dies first. The HybridApp handoff breaks that - it tears - down the RN runtime while Skia host objects are still being released on the main - queue, so ~RuntimeAwareCache destroys cached jsi::Function entries into freed - runtime memory. + down the RN runtime while the cache owner is still being released on the main + queue, so ~RuntimeAwareCache destroys cached JSI values into freed runtime + memory. Fix: extend skia's own secondary-runtime handling to the primary runtime - register a RuntimeLifecycleMonitor listener (from get(), on the JS thread) and - clear _primaryCache in onRuntimeDestroyed, while the runtime is still valid, + reset _primaryCache in onRuntimeDestroyed, while the runtime is still valid, instead of in the later main-queue destructor. A mutex makes that teardown race-safe; no memory is leaked. ``` @@ -24,7 +24,7 @@ - E/App issue: https://github.com/Expensify/App/issues/90135 - PR introducing patch: https://github.com/Expensify/App/pull/93295 -### [@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch](@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch) +### [@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch](@shopify+react-native-skia+2.11.0+002+fallback-to-software-surface.patch) - Reason: @@ -34,18 +34,20 @@ acceleration disabled, GPU blocklisted, or the per-page live context limit exhausted. - WebGLRenderer.onResize() calls CanvasKit.MakeWebGLCanvasSurface(canvas), which - throws instead of returning null when no context can be created. onResize runs - from a ResizeObserver callback, so the throw escapes as an unhandled error that - no try/catch or React error boundary can reach, and the chart area is left blank. - Guarding at the call site cannot prevent this: a capability check runs when the - chart mounts, but the context is created much later, after the CanvasKit WASM - module loads. - - Fix: catch the failure and fall back to CanvasKit.MakeSWCanvasSurface, so the - chart still renders (on the CPU) instead of crashing the page. If that also - fails, leave this.surface null - the constructor already initialises it to null - and both draw() and makeImageSnapshot() null-check it. This mirrors the sibling + WebGLRenderer throws in two places when that happens: its constructor, where + GetWebGLContext / MakeWebGLContext fail, and onResize(), where + MakeOnScreenGLSurface returns null. The constructor runs from a layout effect, + so its throw tears down the view tree; onResize runs from a ResizeObserver + callback, so its throw escapes as an unhandled error that no try/catch or React + error boundary can reach, and the chart area is left blank. Guarding at the call + site cannot prevent either: a capability check runs when the chart mounts, but + the context is created much later, after the CanvasKit WASM module loads. + + Fix: keep the renderer alive without a GrDirectContext and fall back to + CanvasKit.MakeSWCanvasSurface in onResize, so the chart still renders (on the + CPU) instead of crashing the page. If that also fails, leave this.surface null - + the constructor already initialises it to null and both draw() and + makeImageSnapshot() null-check it. This mirrors the sibling renderPictureToSurface path, which already treats a failed WebGL surface as recoverable rather than fatal. Charts on capable clients are unaffected and still render through WebGL. @@ -53,47 +55,21 @@ The software fallback is only used when the canvas can actually provide a 2D context. MakeSWCanvasSurface just stores the canvas and calls getContext("2d") later, when the surface is flushed, so a canvas that already holds a WebGL - context (one whose chart rendered before WebGL became unavailable) would return - null there and turn into an uncatchable "Cannot read properties of null (reading - 'putImageData')". Checking the 2D context up front keeps that case stable. + context (one whose chart rendered before WebGL became unavailable, or one where + only MakeWebGLContext failed) would return null there and turn into an + uncatchable "Cannot read properties of null (reading 'putImageData')". Checking + the 2D context up front keeps that case stable. When no surface can be created at all, the renderer dispatches a bubbling "skia-surface-unavailable" CustomEvent on its canvas (a no-op without a listener). A capability check cannot cover this case - it runs at chart mount while the context is created only after the CanvasKit WASM module loads - so the event is the only reliable signal, and SkiaWebChart uses it to swap in its "unable to - display chart" empty state instead of leaving a blank canvas. + display chart" empty state instead of leaving a blank canvas. The dispatch is + deferred a frame because the renderer is built in a layout effect, which runs + before the effect that attaches the listener. ``` -- Upstream PR/issue: https://github.com/Shopify/react-native-skia/pull/3996 — applies the same defensive handling (and the `skia-surface-unavailable` event) to upstream `main`, where the throws now live in the renderer constructor and `onResize`. Once it ships in a release we consume, this patch can be dropped. +- Upstream PR/issue: https://github.com/Shopify/react-native-skia/pull/3996 - E/App issue: https://github.com/Expensify/App/issues/97104 - PR introducing patch: https://github.com/Expensify/App/pull/97219 - -### [@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch](@shopify+react-native-skia+2.4.14+003+fix-dispose-symbol-eval.patch) - -- Reason: - - ``` - Fixes two iOS failures with one cause: the Top merchants pie chart killing the app - (Sentry APP-K19, fatal unhandled "SyntaxError: Parsing source code unsupported: - Symbol.for('Symbol.dispose');"), and every Line and Bar chart sitting on an indefinite - loading spinner because their fonts never load. - - JsiHostObject::get() falls back to a "dispose symbol" check for any property it does - not recognise, and that check calls jsi::eval(). Hermes without a runtime compiler - rejects that, so any unknown property read on a Skia host object throws instead of - returning undefined: - - - Pie: reaches skia's ReanimatedRecorder, whose - isSharedValue worklet reads _isReanimatedSharedValue on the SkPath. Nothing catches - the throw, so the app terminates. - - Line/Bar: Skia.Data.fromURI resolves with an SkData host object and promise - resolution reads .then on it, so every typeface load rejects, the font manager is - never built, and the charts never leave their loading state. - - Fix: obtain the symbol through runtime.global() instead of eval. - ``` - -- 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 From 9db59469c06be6ad7dbd91ee97a27f3a308f0589 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 12:09:37 +0200 Subject: [PATCH 2/2] Upgrade skia in victory-chart-renderer package --- package-lock.json | 19 +++++-------------- server/stubs/react-native/index.ts | 4 +++- server/victory-chart-renderer/package.json | 4 ++-- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1633bd981ed..83b2115d49e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16755,15 +16755,6 @@ } } }, - "node_modules/@shopify/react-native-skia/node_modules/canvaskit-wasm": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz", - "integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==", - "license": "BSD-3-Clause", - "dependencies": { - "@webgpu/types": "0.1.21" - } - }, "node_modules/@sideway/address": { "version": "4.1.5", "devOptional": true, @@ -21850,9 +21841,9 @@ "license": "MIT" }, "node_modules/canvaskit-wasm": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", - "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.41.0.tgz", + "integrity": "sha512-cnbL02NFB3yOYMF/MtxViZHgD1vh55Pvy+zR8q4JuFvyCPejZP3eClkt2GuZ0S7jOmGMCJXaHBasbMChbR9JZg==", "license": "BSD-3-Clause", "dependencies": { "@webgpu/types": "0.1.21" @@ -42518,8 +42509,8 @@ "name": "@expensify/victory-chart-renderer", "version": "0.0.0", "dependencies": { - "@shopify/react-native-skia": "^2.4.14", - "canvaskit-wasm": "0.40.0", + "@shopify/react-native-skia": "^2.11.0", + "canvaskit-wasm": "0.41.0", "react": "19.2.0", "react-native-render-html": "6.3.1" }, diff --git a/server/stubs/react-native/index.ts b/server/stubs/react-native/index.ts index 3a705c477e71..3c9ce261c0be 100644 --- a/server/stubs/react-native/index.ts +++ b/server/stubs/react-native/index.ts @@ -85,5 +85,7 @@ const Dimensions = { get: () => ({width: 680, height: 530, scale: 1, fontScale: 1}), }; +const codegenNativeComponent = () => passThroughComponent; + export type {LayoutChangeEvent, ViewStyle, ImageStyle, TextStyle, StyleProp, TransformsStyle}; -export {AppRegistry, AppState, Dimensions, NativeModules, View, Image, Text, Platform, PixelRatio, StyleSheet, findNodeHandle, TurboModuleRegistry}; +export {AppRegistry, AppState, Dimensions, NativeModules, View, Image, Text, Platform, PixelRatio, StyleSheet, codegenNativeComponent, findNodeHandle, TurboModuleRegistry}; diff --git a/server/victory-chart-renderer/package.json b/server/victory-chart-renderer/package.json index 1d34ede8cda6..b5ff4df0a6d2 100644 --- a/server/victory-chart-renderer/package.json +++ b/server/victory-chart-renderer/package.json @@ -11,8 +11,8 @@ "build:macos": "bun run scripts/build.ts --target bun-darwin-arm64 --outfile dist/victory-chart-renderer-darwin-arm64" }, "dependencies": { - "canvaskit-wasm": "0.40.0", - "@shopify/react-native-skia": "^2.4.14", + "canvaskit-wasm": "0.41.0", + "@shopify/react-native-skia": "^2.11.0", "react": "19.2.0", "react-native-render-html": "6.3.1" },