diff --git a/examples/.storybook/main.ts b/examples/.storybook/main.ts index 992f778..c150fd5 100644 --- a/examples/.storybook/main.ts +++ b/examples/.storybook/main.ts @@ -39,6 +39,27 @@ const config: StorybookConfig = { '../../' ); + /** + * Every story renders on webgl2. + * + * The three aliases above all point at the rive-react root, whose + * `dist/index.js` imports `@rive-app/canvas` unconditionally. So the + * `@rive-app/react-*` name a story imports selects nothing: every story + * shares one backend, and this is where it is chosen. + * + * webgl2 because the GPU Canvas stories need it — their .riv files are + * shader-driven and canvas2d has no shader stage, so those canvases render + * blank on canvas2d + * + * To check something against canvas2d, drop this alias rather than adding a + * flag — it is one line, and a half-configured backend is worse than an + * edit. + */ + config.resolve.alias['@rive-app/canvas'] = path.resolve( + __dirname, + '../../node_modules/@rive-app/webgl2' + ); + config.module?.rules?.push({ test: /\.(ts|tsx|js|jsx)$/, include: [ diff --git a/examples/public/multi-stage.riv b/examples/public/multi-stage.riv new file mode 100644 index 0000000..93b06aa Binary files /dev/null and b/examples/public/multi-stage.riv differ diff --git a/examples/public/ore.riv b/examples/public/ore.riv new file mode 100644 index 0000000..1c36b12 Binary files /dev/null and b/examples/public/ore.riv differ diff --git a/examples/src/components/GPUCanvasDeferred.stories.ts b/examples/src/components/GPUCanvasDeferred.stories.ts new file mode 100644 index 0000000..41b9f25 --- /dev/null +++ b/examples/src/components/GPUCanvasDeferred.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import GPUCanvasDeferred from './GPUCanvasDeferred'; + +const meta = { + title: 'GPUCanvasDeferred', + component: GPUCanvasDeferred, + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/examples/src/components/GPUCanvasDeferred.tsx b/examples/src/components/GPUCanvasDeferred.tsx new file mode 100644 index 0000000..9e897d8 --- /dev/null +++ b/examples/src/components/GPUCanvasDeferred.tsx @@ -0,0 +1,256 @@ +import React from 'react'; +import { + useRive, + useRiveFile, + Fit, + Alignment, + Layout, +} from '@rive-app/react-webgl2'; +import type { UseRiveParameters } from '@rive-app/react-webgl2'; + +/** + * GPU Canvas — multi-instance matrix + */ + +interface RivExample { + src: string; + artboard: string; + stateMachine: string; + width: number; + height: number; +} + +// Shader-driven files, so they need the webgl2 alias in .storybook/main.ts. +const ORE: RivExample = { + src: 'ore.riv', + artboard: 'Artboard', + stateMachine: 'State Machine 1', + width: 900, + height: 320, +}; + +const MULTI_STAGE: RivExample = { + ...ORE, + src: 'multi-stage.riv', + width: 500, + height: 500, +}; + +const CARD_WIDTH = 380; +const LAYOUT = new Layout({ fit: Fit.Contain, alignment: Alignment.Center }); + +const paramsFor = (riv: RivExample) => ({ + autoplay: true, + artboard: riv.artboard, + stateMachine: riv.stateMachine, + layout: LAYOUT, +}); + +interface CardProps { + title: string; + expectDeferred: boolean; + /** `null` until a pre-imported RiveFile is ready; useRive skips construction. */ + params: UseRiveParameters; + riv: RivExample; +} + +const Card = ({ title, expectDeferred, params, riv }: CardProps) => { + const { rive, RiveComponent } = useRive(params); + + let label = 'loading…'; + let color: string = COLORS.textMuted; + + if (rive) { + const active = rive.deferredRendererActive; + const ok = active === expectDeferred; + label = `${active ? 'deferred' : 'immediate'} ${ + ok ? '✓' : `✗ expected ${expectDeferred ? 'deferred' : 'immediate'}` + }`; + color = ok ? COLORS.pass : COLORS.fail; + } + + return ( +
+

{title}

+
+ +
+
{label}
+
+ ); +}; + +const Section = ({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) => ( +
+

{title}

+

{description}

+
{children}
+
+); + +const deferredCard = (title: string, riv: RivExample) => ( + +); + +/** C and D need the file imported for GPU Canvas before any instance uses it. */ +const SharedFileScenarios = () => { + const shared = useRiveFile({ src: ORE.src, enableGPUCanvas: true }); + const mismatch = useRiveFile({ src: ORE.src, enableGPUCanvas: true }); + + const sharedReady = shared.status === 'success' ? shared.riveFile : null; + const mismatchReady = mismatch.status === 'success' ? mismatch.riveFile : null; + + return ( + <> +
+ {['shared file #1 (claims session)', 'shared file #2 (re-import)'].map( + (title) => ( + + ) + )} +
+ +
+ +
+ + ); +}; + +const GPUCanvasDeferred = () => ( +
+
+

GPU Canvas — multi-instance matrix

+

+ Four arrangements of deferred and immediate instances on one page. Each + card reports the mode it resolved to and whether that matches the + scenario. Keep devtools open — C and D are supposed to warn. +

+ +
+ {deferredCard('deferred #1 (ore.riv)', ORE)} + {deferredCard('deferred #2 (ore.riv, own copy)', ORE)} + {deferredCard('deferred #3 (multi-stage.riv)', MULTI_STAGE)} +
+ +
+ {deferredCard('deferred', ORE)} + +
+ + +
+
+); + +const COLORS = { + bg: '#1a1a1a', + panel: '#252525', + border: '#3a3a3a', + text: '#e0e0e0', + textMuted: '#999', + pass: '#4caf50', + fail: '#ff4d40', +}; + +const styles: Record = { + page: { + background: COLORS.bg, + color: COLORS.text, + fontFamily: 'system-ui, sans-serif', + minHeight: '100vh', + boxSizing: 'border-box', + padding: 24, + }, + inner: { width: 'min(92%, 1100px)', margin: '0 auto' }, + h1: { margin: '0 0 6px', fontSize: 22, fontWeight: 600 }, + intro: { + color: COLORS.textMuted, + fontSize: 14, + lineHeight: 1.6, + margin: '0 0 20px', + }, + section: { marginBottom: 28 }, + sectionTitle: { fontSize: 15, fontWeight: 600, margin: '0 0 4px' }, + sectionDesc: { + color: COLORS.textMuted, + fontSize: 13, + lineHeight: 1.5, + margin: '0 0 12px', + }, + grid: { display: 'flex', flexWrap: 'wrap', gap: 16 }, + card: { + background: COLORS.panel, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + padding: 12, + }, + cardTitle: { fontSize: 13, fontWeight: 600, margin: '0 0 8px' }, + status: { marginTop: 8, fontSize: 13, fontWeight: 600 }, +}; + +export default GPUCanvasDeferred; diff --git a/package-lock.json b/package-lock.json index dd00f06..be1a7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "4.33.1", "license": "MIT", "dependencies": { - "@rive-app/canvas": "2.41.1", - "@rive-app/canvas-lite": "2.41.1", - "@rive-app/webgl2": "2.41.1" + "@rive-app/canvas": "2.42.0", + "@rive-app/canvas-lite": "2.42.0", + "@rive-app/webgl2": "2.42.0" }, "devDependencies": { "@babel/core": "^7.18.0", @@ -1357,21 +1357,21 @@ } }, "node_modules/@rive-app/canvas": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.41.1.tgz", - "integrity": "sha512-6pEi7q0NBMURLdgnl7DQK2yaEuCqi09bXOhHlPIh0+9undsPrNR6fmjRLNdV3xtmV2U9QcatTJvmB5MxZL7BIg==", + "version": "2.42.0", + "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.42.0.tgz", + "integrity": "sha512-ByXG7i8PZGFhPDUA9tkjEbB/C1cjs775IxFdPrWiQDN/WYMU6MirI2x0kGjHHwdfUKuG2HSkWEUI5SBeZm/hEA==", "license": "MIT" }, "node_modules/@rive-app/canvas-lite": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.41.1.tgz", - "integrity": "sha512-z3PK0Yeta/qQ/f75gOAXe4L01ChJAV99ouic4IBetnuHg7OT7SBr15vx50tOb8rgX9jOc+OuGgcuz4xRKqB2qw==", + "version": "2.42.0", + "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.42.0.tgz", + "integrity": "sha512-U3X0AJxOQFMbBYg0Svb4cmln8SJpOQLm2c54sCj/iMo4liiw15GKQbTChQrGJwEqrGkjS+5R5ZOdVqHPgg964A==", "license": "MIT" }, "node_modules/@rive-app/webgl2": { - "version": "2.41.1", - "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.41.1.tgz", - "integrity": "sha512-1iu3ZTtvcETI3kPsTXA9wV1xxE56/FomsIpN87nhpTwjutY9wFvi55QZo1T76/I1He2OCra/IIPk2OGmKRC0Vg==", + "version": "2.42.0", + "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.42.0.tgz", + "integrity": "sha512-l5KJsxSc39lirk5+EHKKwkEdV84vI14zc7N7B5jBdVAk3E4Tg79C0gd5JmScoJaoRkc6wi6X952KbALji+NQZg==", "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { diff --git a/package.json b/package.json index 3b03af0..a4a7bde 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ }, "homepage": "https://github.com/rive-app/rive-react#readme", "dependencies": { - "@rive-app/canvas": "2.41.1", - "@rive-app/canvas-lite": "2.41.1", - "@rive-app/webgl2": "2.41.1" + "@rive-app/canvas": "2.42.0", + "@rive-app/canvas-lite": "2.42.0", + "@rive-app/webgl2": "2.42.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" diff --git a/src/components/Rive.tsx b/src/components/Rive.tsx index 7019091..6848ab9 100644 --- a/src/components/Rive.tsx +++ b/src/components/Rive.tsx @@ -39,9 +39,33 @@ export interface RiveProps { */ layout?: Layout; /** - * For `@rive-app/react-webgl2`, sets this property to maintain a single WebGL context for multiple canvases. **We recommend to keep the default value** when rendering multiple Rive instances on a page. + * For `@rive-app/react-webgl2`, share one WebGL context across every canvas + * on the page instead of giving this canvas its own. + * + * **We recommend leaving this unset**, which picks the right value for you: + * - `true` normally, so multiple Rive graphics on a page share one context. + * - `false` when `enableGPUCanvas` is on, because GPU Canvas needs a context + * of its own for each ``. + * + * Setting it explicitly always wins. Setting it to `true` alongside + * `enableGPUCanvas` means GPU Canvas content will not draw. */ useOffscreenRenderer?: boolean; + /** + * @experimental This API is early and may encounter breaking behavior change without a major version bump + * + * Render GPU Canvas content, which draws through the runtime's deferred + * renderer. False by default. + * + * IMPORTANT: Turning this on makes `useOffscreenRenderer` default to `false` for this + * component if it was not specified, since a GPU Canvas session records for a single `` context. Highly recommended to + * only set `true` on this property for graphics that use GPU Canvas content, and leave + * `enableGPUCanvas` unset, or set to `false` for other graphics on the page. + * + * Each of these takes its own WebGL context, and browsers cap how many a page may + * have (the limit varies by browser), so enabling it on too many at once can throw. + */ + enableGPUCanvas?: boolean; /** * Specify whether to disable Rive listeners on the canvas, thus preventing any event listeners to be attached to the canvas element */ @@ -75,7 +99,11 @@ const Rive = ({ animations, stateMachines, layout, - useOffscreenRenderer = true, + // No default: useRive treats a defined value as an explicit choice, and GPU + // Canvas has to be able to bend the default. Left out of `options` below when + // undefined, it falls through to defaultOptions — which will still be `true`. + useOffscreenRenderer, + enableGPUCanvas, shouldDisableRiveListeners = false, shouldResizeCanvasToContainer = true, automaticallyHandleEvents = false, @@ -92,11 +120,12 @@ const Rive = ({ autoplay: true, shouldDisableRiveListeners, automaticallyHandleEvents, + enableGPUCanvas, }; const options = { - useOffscreenRenderer, shouldResizeCanvasToContainer, + ...(useOffscreenRenderer !== undefined && { useOffscreenRenderer }), }; const { RiveComponent } = useRive(params, options); diff --git a/src/hooks/useRive.tsx b/src/hooks/useRive.tsx index 0446fd4..3627513 100644 --- a/src/hooks/useRive.tsx +++ b/src/hooks/useRive.tsx @@ -10,7 +10,7 @@ import { Rive, EventType, Fit } from '@rive-app/canvas'; import { UseRiveParameters, UseRiveOptions, RiveState } from '../types'; import useResizeCanvas from './useResizeCanvas'; import useDevicePixelRatio from './useDevicePixelRatio'; -import { getOptions } from '../utils'; +import { defaultOptions, getOptions, safeCleanup } from '../utils'; import useIntersectionObserver from './useIntersectionObserver'; type RiveComponentProps = { @@ -76,6 +76,46 @@ export default function useRive( const isParamsLoaded = Boolean(riveParams); const options = getOptions(opts); + /** + * GPU Canvas records into a deferred session bound to ONE canvas. The + * offscreen renderer is the opposite: it shares a single GL context across + * every `` on the page. Hand the JS runtime both and it warns, re-imports the file in + * immediate mode, and GPU Canvas content silently never draws. + * + * The JS runtime's own default for `useOffscreenRenderer` is `false`; the `true` + * is React runtime's (see `defaultOptions`). So opting into GPU Canvas only has + * to unwind React's default — an explicit value from the caller still wins, and + * still loses GPU Canvas, which the effect below warns about. + */ + const explicitOffscreenRenderer = + riveParams?.useOffscreenRenderer ?? opts.useOffscreenRenderer; + + // A file's rendering mode is fixed at import and wins over the instance's own + // flag, so a `riveFile` built with `enableGPUCanvas: true` needs a + // non-offscreen renderer even when `useRive` was never told about it. + // `deferredRequested` is marked `@internal` on the runtime's RiveFile. + const wantsGPUCanvas = + Boolean(riveParams?.enableGPUCanvas) || + Boolean(riveParams?.riveFile?.deferredRequested); + + const useOffscreenRenderer = + explicitOffscreenRenderer ?? + (wantsGPUCanvas ? false : defaultOptions.useOffscreenRenderer); + + // The runtime warns about this too, but from where it sits it cannot say that + // a hook option is what turned the offscreen renderer on. + useEffect(() => { + if (wantsGPUCanvas && useOffscreenRenderer) { + console.warn( + '[Rive] GPU Canvas and `useOffscreenRenderer` cannot both be on. ' + + 'A GPU Canvas session records for a single , while the offscreen ' + + 'renderer shares one context across every on the page. This ' + + 'instance falls back to immediate rendering and GPU Canvas content will ' + + 'not draw — drop the explicit `useOffscreenRenderer: true` to use it.' + ); + } + }, [wantsGPUCanvas, useOffscreenRenderer]); + const devicePixelRatio = useDevicePixelRatio(); /** @@ -130,15 +170,16 @@ export default function useRive( let isLoaded = rive != null; let r: Rive | null; if (rive == null) { - const { useOffscreenRenderer } = options; const { onRiveReady, ...restRiveParams } = riveParams; r = new Rive({ - useOffscreenRenderer, ...restRiveParams, + useOffscreenRenderer, canvas: canvasElem, }); if (riveRef.current != null) { - riveRef.current!.cleanup(); + safeCleanup('replacing a previous instance', () => + riveRef.current!.cleanup() + ); } riveRef.current = r; r.on(EventType.Load, () => { @@ -154,13 +195,13 @@ export default function useRive( setRive(r); } else { // If unmounted, cleanup the rive object immediately - r!.cleanup(); + safeCleanup('unmounted before load', () => r!.cleanup()); } }); } return () => { if (!isLoaded) { - r?.cleanup(); + safeCleanup('teardown before load', () => r?.cleanup()); } }; }, [canvasElem, isParamsLoaded, rive]); @@ -242,7 +283,8 @@ export default function useRive( useEffect(() => { return () => { if (rive) { - rive.cleanup(); + // setRive(null) runs either way — a half-destroyed instance is unusable. + safeCleanup('unmount', () => rive.cleanup()); setRive(null); } }; @@ -251,7 +293,7 @@ export default function useRive( useEffect(() => { return () => { if (riveRef.current != null) { - riveRef.current!.cleanup(); + safeCleanup('final unmount', () => riveRef.current!.cleanup()); } }; }, []); diff --git a/src/hooks/useRiveFile.ts b/src/hooks/useRiveFile.ts index f44e056..45e1df3 100644 --- a/src/hooks/useRiveFile.ts +++ b/src/hooks/useRiveFile.ts @@ -5,6 +5,7 @@ import type { FileStatus, } from '../types'; import { EventType, RiveFile } from '@rive-app/canvas'; +import { safeCleanup } from '../utils'; /** * Custom hook for initializing and managing a RiveFile instance within a component. @@ -47,9 +48,12 @@ function useRiveFile(params: UseRiveFileParameters): RiveFileState { loadRiveFile(); return () => { - file?.cleanup(); + safeCleanup('RiveFile unmount', () => file?.cleanup()); }; - }, [params.src, params.buffer]); + // `enableGPUCanvas` is in here because a file's rendering mode is fixed at + // import and has no setter — toggling it has to re-import, or it is a + // silent no-op. + }, [params.src, params.buffer, params.enableGPUCanvas]); return { riveFile, status }; } diff --git a/src/types.ts b/src/types.ts index aff213f..6628741 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,18 @@ export type UseRiveOptions = { useDevicePixelRatio: boolean; customDevicePixelRatio: number; fitCanvasToArtboardHeight: boolean; + /** + * For `@rive-app/react-webgl2`, share one WebGL context across every canvas + * on the page instead of giving this canvas its own. + * + * **We recommend leaving this unset**, which picks the right value for you: + * - `true` normally, so multiple Rive graphics on a page share one context. + * - `false` when `enableGPUCanvas` is on, because GPU Canvas needs a context + * of its own for each ``. + * + * Setting it explicitly always wins. Setting it to `true` alongside + * `enableGPUCanvas` means GPU Canvas content will not draw. + */ useOffscreenRenderer: boolean; shouldResizeCanvasToContainer: boolean; shouldUseIntersectionObserver?: boolean; diff --git a/src/utils.ts b/src/utils.ts index 3f52adb..274aecc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,14 @@ import { UseRiveOptions } from './types'; -const defaultOptions = { +/** + * Hook option defaults. + * + * Note `useOffscreenRenderer: true` — that is this React runtime's default, not the + * JS runtime's. The JS runtime itself defaults it to `false`. useRive needs to know + * which value came from here so GPU Canvas can bend the default without + * overriding an explicit choice. + */ +export const defaultOptions = { useDevicePixelRatio: true, fitCanvasToArtboardHeight: false, useOffscreenRenderer: true, @@ -10,3 +18,21 @@ const defaultOptions = { export function getOptions(opts: Partial) { return Object.assign({}, defaultOptions, opts); } + +/** + * Runs a Rive teardown call without letting it escape into React. + * + * Teardown can also run while replacing an instance, so a failure may leak + * GPU resources while the page remains active. Warn in every build because + * these throws are GPU/driver dependent and surface in the field. + */ +export function safeCleanup(label: string, cleanup: () => void) { + try { + cleanup(); + } catch (error) { + console.warn( + `[Rive] ${label} threw while cleaning up Rive; contained. `, + error + ); + } +} diff --git a/test/useRiveGpuCanvas.test.tsx b/test/useRiveGpuCanvas.test.tsx new file mode 100644 index 0000000..f597324 --- /dev/null +++ b/test/useRiveGpuCanvas.test.tsx @@ -0,0 +1,121 @@ +import { mocked } from 'jest-mock'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +import useRive from '../src/hooks/useRive'; +import * as rive from '@rive-app/canvas'; +import { UseRiveOptions, UseRiveParameters } from '../src/types'; + +/** + * GPU Canvas and the offscreen renderer are mutually exclusive: a GPU Canvas + * session records for a single canvas, while the offscreen renderer shares one + * GL context across every canvas on the page (so it has no `attachSession`). + * + * The JS runtime defaults `useOffscreenRenderer` to false; rive-react defaults it + * to true. These tests pin the resolution rule that reconciles the two, since + * getting it wrong means GPU Canvas silently never draws for React users. + */ +describe('useRive — GPU Canvas / offscreen renderer resolution', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + mocked(rive.Rive).mockClear(); + // @ts-ignore — a bare stub is enough; nothing here drives playback. + mocked(rive.Rive).mockImplementation(() => ({ + on: jest.fn(), + stop: jest.fn(), + cleanup: jest.fn(), + })); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + /** Mounts the hook, attaches a canvas, and returns the Rive constructor args. */ + async function constructorParams( + params: UseRiveParameters, + opts?: Partial + ) { + const canvasSpy = document.createElement('canvas'); + const { result } = renderHook(() => useRive(params, opts)); + + await act(async () => { + result.current.setCanvasRef(canvasSpy); + }); + await waitFor(() => { + expect(mocked(rive.Rive)).toHaveBeenCalled(); + }); + + return mocked(rive.Rive).mock.calls[0][0]; + } + + it('keeps the offscreen renderer on by default', async () => { + const args = await constructorParams({ src: 'file-src' }); + expect(args.useOffscreenRenderer).toBe(true); + }); + + it('turns the offscreen renderer off when enableGPUCanvas is set', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + }); + expect(args.useOffscreenRenderer).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('turns it off for a riveFile imported with GPU Canvas, even when the instance did not ask', async () => { + // The file's mode is fixed at import and wins over the instance's flag, so + // the renderer has to follow the file. + const riveFile = { deferredRequested: true } as unknown as rive.RiveFile; + + const args = await constructorParams({ riveFile }); + expect(args.useOffscreenRenderer).toBe(false); + }); + + it('leaves it on for a riveFile imported without GPU Canvas', async () => { + const riveFile = { deferredRequested: false } as unknown as rive.RiveFile; + + const args = await constructorParams({ riveFile }); + expect(args.useOffscreenRenderer).toBe(true); + }); + + it('lets an explicit option win over GPU Canvas, and warns', async () => { + const args = await constructorParams( + { src: 'file-src', enableGPUCanvas: true }, + { useOffscreenRenderer: true } + ); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('lets an explicit riveParams value win over GPU Canvas', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + useOffscreenRenderer: true, + }); + + expect(args.useOffscreenRenderer).toBe(true); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('honors an explicit false with no GPU Canvas in play', async () => { + const args = await constructorParams( + { src: 'file-src' }, + { useOffscreenRenderer: false } + ); + + expect(args.useOffscreenRenderer).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('forwards enableGPUCanvas through to the runtime', async () => { + const args = await constructorParams({ + src: 'file-src', + enableGPUCanvas: true, + }); + expect(args).toMatchObject({ enableGPUCanvas: true }); + }); +});