From d03a70a6dfbfb059fbd4cb73ffce5f8c437d3656 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 12:08:11 +0300 Subject: [PATCH] =?UTF-8?q?fix(=F0=9F=8C=8E):=20type=20MakeWebGPUCanvasCon?= =?UTF-8?q?text=20as=20nullable=20and=20keep=20getNativeSurface()=20workin?= =?UTF-8?q?g=20after=20unmount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global RNWebGPU type said MakeWebGPUCanvasContext always returns a context, while the web polyfill returns null without WebGPU support (#489); the polyfill hid that behind a @ts-expect-error over its whole object and a cast. The type now says null, and the polyfill is checked as the members of the native module it provides. On the web, getNativeSurface() and MakeWebGPUCanvasContext read the canvas element without a null check, so both threw a TypeError once the canvas had unmounted. getNativeSurface() now answers what native answers for a context it no longer knows, a 0 x 0 surface whose DOM methods do nothing, and MakeWebGPUCanvasContext returns null. --- packages/webgpu/src/WebPolyfillGPUModule.ts | 49 ++++++++++++++----- .../src/__tests__/CanvasContext.spec.ts | 12 +++++ .../webgpu/src/__tests__/WebPolyfill.spec.ts | 41 +++++++++++++--- packages/webgpu/src/index.tsx | 3 +- 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/webgpu/src/WebPolyfillGPUModule.ts b/packages/webgpu/src/WebPolyfillGPUModule.ts index 968503726..555b175ae 100644 --- a/packages/webgpu/src/WebPolyfillGPUModule.ts +++ b/packages/webgpu/src/WebPolyfillGPUModule.ts @@ -1,11 +1,33 @@ +import type { NativeCanvas } from "./types"; import { contextIdToId } from "./utils"; const fabric = true; -function getNativeSurface(contextId: number) { - const canvas = document.getElementById( +function getCanvasElement(contextId: number) { + return document.getElementById( contextIdToId(contextId), - ) as HTMLCanvasElement; + ) as HTMLCanvasElement | null; +} + +// Native's answer for a context it no longer knows (RNWebGPU::getNativeSurface). +const makeRetiredSurface = (): NativeCanvas => ({ + surface: BigInt(0), + height: 0, + width: 0, + clientHeight: 0, + clientWidth: 0, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, +}); + +function getNativeSurface(contextId: number): NativeCanvas { + const canvas = getCanvasElement(contextId); + if (canvas === null) { + return makeRetiredSurface(); + } // Layout size, like the native view size in dp: unlike // getBoundingClientRect() it ignores CSS transforms. @@ -31,9 +53,10 @@ function makeWebGPUCanvasContext( width: number, height: number, ) { - const canvas = document.getElementById( - contextIdToId(contextId), - ) as HTMLCanvasElement; + const canvas = getCanvasElement(contextId); + if (canvas === null) { + return null; + } const dpr = window.devicePixelRatio || 1; const pixelWidth = (width * dpr).toString(); @@ -58,14 +81,16 @@ function makeWebGPUCanvasContext( return Object.assign(context, { present: () => {} }); } -// @ts-expect-error - polyfill for RNWebGPU native module -window.RNWebGPU = { +const polyfill: Pick< + typeof RNWebGPU, + "getNativeSurface" | "MakeWebGPUCanvasContext" | "destroyContext" | "fabric" +> = { getNativeSurface, - // Unlike native, this can return null; Canvas.getContext() is typed - // `RNCanvasContext | null` and passes it through. - MakeWebGPUCanvasContext: - makeWebGPUCanvasContext as typeof RNWebGPU.MakeWebGPUCanvasContext, + MakeWebGPUCanvasContext: makeWebGPUCanvasContext, // On web the browser owns the canvas/context lifecycle; nothing to retire. destroyContext: (_contextId: number) => {}, fabric, }; + +// The web provides these members of the native module and no others. +window.RNWebGPU = polyfill as typeof RNWebGPU; diff --git a/packages/webgpu/src/__tests__/CanvasContext.spec.ts b/packages/webgpu/src/__tests__/CanvasContext.spec.ts index 5277e5422..0743d5b42 100644 --- a/packages/webgpu/src/__tests__/CanvasContext.spec.ts +++ b/packages/webgpu/src/__tests__/CanvasContext.spec.ts @@ -8,6 +8,9 @@ describe("Native canvas context", () => { const result = await client.eval(({ device, gpu }) => { // The harness uses a JS offscreen polyfill; exercise the native context. const ctx = RNWebGPU.MakeWebGPUCanvasContext(-1, 16, 16); + if (ctx === null) { + throw new Error("native returned no canvas context"); + } ctx.configure({ device, format: gpu.getPreferredCanvasFormat() }); try { const texture = ctx.getCurrentTexture(); @@ -37,9 +40,15 @@ describe("Native canvas context", () => { const result = await client.eval(({ device, gpu }) => { const config = { device, format: gpu.getPreferredCanvasFormat() }; const kept = RNWebGPU.MakeWebGPUCanvasContext(-2, 16, 16); + if (kept === null) { + throw new Error("native returned no canvas context"); + } kept.configure(config); RNWebGPU.destroyContext(-2); const next = RNWebGPU.MakeWebGPUCanvasContext(-2, 16, 16); + if (next === null) { + throw new Error("native returned no canvas context"); + } next.configure(config); try { const texture = next.getCurrentTexture(); @@ -63,6 +72,9 @@ describe("Native canvas context", () => { const result = await client.eval( ({ device, gpu, operation: transition }) => { const ctx = RNWebGPU.MakeWebGPUCanvasContext(-1, 16, 16); + if (ctx === null) { + throw new Error("native returned no canvas context"); + } const config = { device, format: gpu.getPreferredCanvasFormat() }; ctx.configure(config); try { diff --git a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts index 92d2e539a..93f699ace 100644 --- a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts +++ b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts @@ -23,15 +23,12 @@ const makeCanvas = (context: object | null): FakeCanvas => ({ }, }); -type Polyfill = { - MakeWebGPUCanvasContext: ( - contextId: number, - width: number, - height: number, - ) => (object & { present: () => void }) | null; -}; +type Polyfill = Pick< + typeof RNWebGPU, + "getNativeSurface" | "MakeWebGPUCanvasContext" +>; -const loadPolyfill = (canvas: FakeCanvas): Polyfill => { +const loadPolyfill = (canvas: FakeCanvas | null): Polyfill => { const g = globalThis as Record; g.window = { devicePixelRatio: 2 }; g.document = { getElementById: () => canvas }; @@ -64,4 +61,32 @@ describe("Web polyfill", () => { const RNWebGPU = loadPolyfill(makeCanvas(null)); expect(RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50)).toBeNull(); }); + + it("reports a 0 x 0 surface once the canvas element is gone, as native does", () => { + const surface = loadPolyfill(null).getNativeSurface(1); + expect({ + surface: surface.surface, + width: surface.width, + height: surface.height, + clientWidth: surface.clientWidth, + clientHeight: surface.clientHeight, + }).toEqual({ + surface: BigInt(0), + width: 0, + height: 0, + clientWidth: 0, + clientHeight: 0, + }); + expect(() => { + surface.addEventListener("pointerdown", () => {}); + surface.removeEventListener("pointerdown", () => {}); + surface.dispatchEvent(new Event("pointerdown")); + surface.setPointerCapture(7); + surface.releasePointerCapture(7); + }).not.toThrow(); + }); + + it("returns no context once the canvas element is gone", () => { + expect(loadPolyfill(null).MakeWebGPUCanvasContext(1, 100, 50)).toBeNull(); + }); }); diff --git a/packages/webgpu/src/index.tsx b/packages/webgpu/src/index.tsx index 9c5768ae8..58586089c 100644 --- a/packages/webgpu/src/index.tsx +++ b/packages/webgpu/src/index.tsx @@ -60,11 +60,12 @@ declare global { gpu: GPU; fabric: boolean; getNativeSurface: (contextId: number) => NativeCanvas; + // null on the web, without WebGPU support or once the canvas is gone. MakeWebGPUCanvasContext: ( contextId: number, width: number, height: number, - ) => RNCanvasContext; + ) => RNCanvasContext | null; // Retires a canvas context; called by Canvas on unmount (the Canvas owns // the native registry entry for its contextId). destroyContext: (contextId: number) => void;