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/details.md b/patches/@shopify/react-native-skia/details.md index e513c934704c..62ad270d3b4e 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -23,48 +23,3 @@ - Upstream PR/issue: - 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) - -- Reason: - - ``` - Fixes an uncatchable crash on web (Sentry APP-7MV: "failed to create webgl - context: err 0") when the browser cannot create a WebGL2 context - hardware - 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 - 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. - - 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. - - 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. - ``` - -- 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. -- E/App issue: https://github.com/Expensify/App/issues/97104 -- PR introducing patch: https://github.com/Expensify/App/pull/97219 diff --git a/src/components/Charts/SkiaWebChart/index.tsx b/src/components/Charts/SkiaWebChart/index.tsx index 2756d38ea2fd..15cf29d06531 100644 --- a/src/components/Charts/SkiaWebChart/index.tsx +++ b/src/components/Charts/SkiaWebChart/index.tsx @@ -8,16 +8,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import viewRef from '@src/types/utils/viewRef'; - import type {ComponentType} from 'react'; import {WithSkiaWeb} from '@shopify/react-native-skia/lib/module/web'; -import React, {useRef, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import isSkiaWebSupported from './isSkiaWebSupported'; -import useIsSkiaSurfaceUnavailable from './useIsSkiaSurfaceUnavailable'; type SkiaWebChartProps = { /** Lazily imports the Skia-backed chart component to render. */ @@ -57,18 +54,13 @@ function ChartUnavailable() { // eslint-disable-next-line @typescript-eslint/no-restricted-types function SkiaWebChart({getComponent, componentProps}: SkiaWebChartProps) { const styles = useThemeStyles(); - const containerRef = useRef(null); // Probe once per mount (not per render) so re-rendering doesn't repeatedly create WebGL contexts, // while a fresh chart still re-checks capability instead of trusting a stale session-wide result. const [isSupported] = useState(() => isSkiaWebSupported()); - // The probe can pass while the renderer still ends up without a drawing surface, so also listen for the - // renderer reporting that and degrade to the empty state. - const isSurfaceUnavailable = useIsSkiaSurfaceUnavailable(containerRef); - // If unsupported, the device can't give CanvasKit a usable WebGL surface. - if (!isSupported || isSurfaceUnavailable) { + if (!isSupported) { return ; } @@ -79,14 +71,12 @@ function SkiaWebChart({getComponent, componentProps}: Ski ); return ( - - `/${file}`}} - getComponent={getComponent} - componentProps={componentProps} - fallback={fallback} - /> - + `/${file}`}} + getComponent={getComponent} + componentProps={componentProps} + fallback={fallback} + /> ); } diff --git a/src/components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable.ts b/src/components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable.ts deleted file mode 100644 index e2866fb911f3..000000000000 --- a/src/components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type {RefObject} from 'react'; - -import {useEffect, useState} from 'react'; - -/** Dispatched by the patched Skia web renderer when it cannot create any drawing surface (see `patches/@shopify/react-native-skia`). */ -const SURFACE_UNAVAILABLE_EVENT = 'skia-surface-unavailable'; - -/** - * True once a Skia renderer inside the container has reported that it cannot create a drawing surface. - * - * The capability probe can pass while creating the actual surface still fails: the surface is created only - * once the CanvasKit WASM module has loaded, and WebGL can become exhausted in between. Only the renderer - * knows when that happens, so it announces the failure with a bubbling event and this hook listens for it. - */ -function useIsSkiaSurfaceUnavailable(containerRef: RefObject): boolean { - const [isSurfaceUnavailable, setIsSurfaceUnavailable] = useState(false); - - useEffect(() => { - const container = containerRef.current; - // Outside the browser the ref does not hold a DOM element and the event can never fire. - if (!(container instanceof HTMLElement)) { - return; - } - const markSurfaceUnavailable = () => setIsSurfaceUnavailable(true); - container.addEventListener(SURFACE_UNAVAILABLE_EVENT, markSurfaceUnavailable); - return () => container.removeEventListener(SURFACE_UNAVAILABLE_EVENT, markSurfaceUnavailable); - }, [containerRef]); - - return isSurfaceUnavailable; -} - -export default useIsSkiaSurfaceUnavailable; diff --git a/tests/unit/components/Charts/useIsSkiaSurfaceUnavailable.test.ts b/tests/unit/components/Charts/useIsSkiaSurfaceUnavailable.test.ts deleted file mode 100644 index 6f1202789b0a..000000000000 --- a/tests/unit/components/Charts/useIsSkiaSurfaceUnavailable.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import useIsSkiaSurfaceUnavailable from '@components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable'; - -// The event the patched Skia web renderer dispatches when it cannot create a drawing surface. -const SURFACE_UNAVAILABLE_EVENT = 'skia-surface-unavailable'; - -describe('useIsSkiaSurfaceUnavailable', () => { - it('should report unavailable once the renderer dispatches the surface event', () => { - const container = document.createElement('div'); - const {result} = renderHook(() => useIsSkiaSurfaceUnavailable({current: container})); - - expect(result.current).toBe(false); - - act(() => { - container.dispatchEvent(new CustomEvent(SURFACE_UNAVAILABLE_EVENT, {bubbles: true})); - }); - - expect(result.current).toBe(true); - }); - - it('should hear the event from a canvas nested inside the container, where the renderer dispatches it', () => { - const container = document.createElement('div'); - const canvas = document.createElement('canvas'); - container.appendChild(canvas); - const {result} = renderHook(() => useIsSkiaSurfaceUnavailable({current: container})); - - act(() => { - canvas.dispatchEvent(new CustomEvent(SURFACE_UNAVAILABLE_EVENT, {bubbles: true})); - }); - - expect(result.current).toBe(true); - }); - - it('should stop listening when unmounted', () => { - const container = document.createElement('div'); - const removeListener = jest.spyOn(container, 'removeEventListener'); - const {unmount} = renderHook(() => useIsSkiaSurfaceUnavailable({current: container})); - - unmount(); - - expect(removeListener).toHaveBeenCalledWith(SURFACE_UNAVAILABLE_EVENT, expect.any(Function)); - }); - - it('should stay available when the ref does not hold a DOM element', () => { - const {result} = renderHook(() => useIsSkiaSurfaceUnavailable({current: null})); - - expect(result.current).toBe(false); - }); -});