Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
45 changes: 45 additions & 0 deletions patches/@shopify/react-native-skia/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 18 additions & 8 deletions src/components/Charts/SkiaWebChart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TProps> = {
/** Lazily imports the Skia-backed chart component to render. */
Expand Down Expand Up @@ -54,13 +57,18 @@ function ChartUnavailable() {
// eslint-disable-next-line @typescript-eslint/no-restricted-types
function SkiaWebChart<TProps extends object>({getComponent, componentProps}: SkiaWebChartProps<TProps>) {
const styles = useThemeStyles();
const containerRef = useRef<HTMLElement | null>(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 <ChartUnavailable />;
}

Expand All @@ -71,12 +79,14 @@ function SkiaWebChart<TProps extends object>({getComponent, componentProps}: Ski
);

return (
<WithSkiaWeb
opts={{locateFile: (file: string) => `/${file}`}}
getComponent={getComponent}
componentProps={componentProps}
fallback={fallback}
/>
<View ref={viewRef(containerRef)}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrapper caused regression - Concierge chat is cut off on medium/small screens

Root cause: maxWidth isn't set

<WithSkiaWeb
opts={{locateFile: (file: string) => `/${file}`}}
getComponent={getComponent}
componentProps={componentProps}
fallback={fallback}
/>
</View>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>): 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;
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading