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 new file mode 100644 index 000000000000..aea542f13e86 --- /dev/null +++ b/patches/@shopify/react-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch @@ -0,0 +1,102 @@ +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 62ad270d3b4e..e513c934704c 100644 --- a/patches/@shopify/react-native-skia/details.md +++ b/patches/@shopify/react-native-skia/details.md @@ -23,3 +23,48 @@ - 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 15cf29d06531..2756d38ea2fd 100644 --- a/src/components/Charts/SkiaWebChart/index.tsx +++ b/src/components/Charts/SkiaWebChart/index.tsx @@ -8,13 +8,16 @@ 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, {useState} from 'react'; +import React, {useRef, 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. */ @@ -54,13 +57,18 @@ 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) { + if (!isSupported || isSurfaceUnavailable) { return ; } @@ -71,12 +79,14 @@ 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 new file mode 100644 index 000000000000..e2866fb911f3 --- /dev/null +++ b/src/components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000000..6f1202789b0a --- /dev/null +++ b/tests/unit/components/Charts/useIsSkiaSurfaceUnavailable.test.ts @@ -0,0 +1,50 @@ +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); + }); +});