Repository navigation
Render Skia web charts on a software surface when WebGL is unavailable #97219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mountiny
merged 9 commits into
Expensify:main
from
wildan-m:wildan/97104-skia-webgl-surface-fallback
Aug 10, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
d00675f
Fall back to a software surface when the Skia web chart can't get WebGL
wildan-m 7f6d00c
Move the Skia web surface patch into the library's patch directory
wildan-m 3541c77
Merge remote-tracking branch 'upstream/main' into wildan/97104-skia-w…
wildan-m f53a168
Only use the software surface when the canvas can give a 2D context
wildan-m 187b56f
Announce an unrenderable Skia web chart with a DOM event
wildan-m f3fd002
Show the chart empty state when the Skia renderer reports no surface
wildan-m 5cdd6bf
Retrigger CI after unrelated test shard timeout
wildan-m 86369ec
Link the upstream Skia PR for the web surface patch
wildan-m 0fc5015
Merge remote-tracking branch 'upstream/main' into tmp-97104-merge
wildan-m File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
...eact-native-skia/@shopify+react-native-skia+2.4.14+002+fallback-to-software-surface.patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/components/Charts/SkiaWebChart/useIsSkiaSurfaceUnavailable.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
50 changes: 50 additions & 0 deletions
50
tests/unit/components/Charts/useIsSkiaSurfaceUnavailable.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
maxWidthisn't set