From d5cc427ff072e4ecf61929bbe1465cd045ce8f07 Mon Sep 17 00:00:00 2001 From: Mad Dinh Date: Mon, 24 Aug 2026 20:46:53 +0700 Subject: [PATCH] =?UTF-8?q?fix(=F0=9F=8C=90):=20keep=20the=20web=20canvas?= =?UTF-8?q?=20usable=20when=20its=20layout=20effect=20re-runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebGLRenderer.dispose() called WEBGL_lose_context.loseContext() on the element, which per spec loses that element's context permanently - a later getContext() returns the same dead context and only restoreContext() revives it. But the renderer lives in a layout effect, and effect cleanup does not mean the element is going away: StrictMode's DEV double-invoke and an Activity reveal (how React Navigation keeps inactive routes mounted) both re-run the effect on the very same node. The second construction then hands CanvasKit a dead canvas and faults inside wasm with "Cannot read properties of null (reading 'rangeMin')". What #3929 needed was CanvasKit.deleteContext, which unregisters the context and drops the reference to the detached element - that stays. loseContext only released the drawing buffer eagerly, and it was the one part of dispose() that left the element unusable. The regression test drives the component through StrictMode with a WebGL context mock that models the spec'd behaviour: once lost, the element's context stays lost. Fixes #3976 --- .../skia/src/views/SkiaPictureView.web.tsx | 15 ++- .../__tests__/SkiaPictureView.web.spec.tsx | 109 +++++++++++++++++- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/skia/src/views/SkiaPictureView.web.tsx b/packages/skia/src/views/SkiaPictureView.web.tsx index de62406fe8..69bb61a186 100644 --- a/packages/skia/src/views/SkiaPictureView.web.tsx +++ b/packages/skia/src/views/SkiaPictureView.web.tsx @@ -120,10 +120,17 @@ class WebGLRenderer implements Renderer { this.grContext.delete(); this.grContext = null; } - this.canvas - ?.getContext("webgl2") - ?.getExtension("WEBGL_lose_context") - ?.loseContext(); + // A lost context is permanent and effect cleanup is not unmount, so only + // release the drawing buffer once React has really detached the element. + const { canvas } = this; + queueMicrotask(() => { + if (!canvas.isConnected) { + canvas + .getContext("webgl2") + ?.getExtension("WEBGL_lose_context") + ?.loseContext(); + } + }); if (this.contextHandle) { // Unregister the context from CanvasKit's internal registry, otherwise // it retains the canvas element (and its detached DOM tree) forever. diff --git a/packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx b/packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx index 6344e69c3b..bded02b33c 100644 --- a/packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx +++ b/packages/skia/src/views/__tests__/SkiaPictureView.web.spec.tsx @@ -44,6 +44,39 @@ class ResizeObserverMock { } } +interface WebGLContextMock { + drawingBufferColorSpace: string; + lost: boolean; + getExtension(name: string): { loseContext(): void } | null; + isContextLost(): boolean; +} + +// Models the one detail that matters: a lost context stays lost, and the +// element keeps handing back that same dead context. +const webglContexts = new WeakMap(); + +const webglContextFor = (canvas: HTMLCanvasElement) => { + let context = webglContexts.get(canvas); + if (!context) { + const created: WebGLContextMock = { + drawingBufferColorSpace: "srgb", + lost: false, + getExtension: (name: string) => + name === "WEBGL_lose_context" + ? { + loseContext: () => { + created.lost = true; + }, + } + : null, + isContextLost: () => created.lost, + }; + context = created; + webglContexts.set(canvas, context); + } + return context; +}; + const canvasSize = { width: 0, height: 0 }; const makeRawCanvas = () => ({ @@ -66,13 +99,35 @@ const createCanvasKitMock = () => { releaseResourcesAndAbandonContext: jest.fn(), delete: jest.fn(), }; + const canvasForHandle = new Map(); + const deletedHandles = new Set(); + let nextHandle = 1; const CanvasKitMock = { - GetWebGLContext: jest.fn(() => 1), - MakeWebGLContext: jest.fn(() => grContext), + // A lost context still yields a handle, so the "Could not create a WebGL + // context" guard never fires; MakeWebGLContext is where it faults (#3976). + GetWebGLContext: jest.fn((canvas: HTMLCanvasElement) => { + const handle = nextHandle++; + canvasForHandle.set(handle, canvas); + return handle; + }), + MakeWebGLContext: jest.fn((handle: number) => { + if (deletedHandles.has(handle)) { + return null; + } + const canvas = canvasForHandle.get(handle); + if (canvas && webglContextFor(canvas).lost) { + throw new TypeError( + "Cannot read properties of null (reading 'rangeMin')" + ); + } + return grContext; + }), MakeOnScreenGLSurface: jest.fn((_ctx, width, height) => width === 0 || height === 0 ? null : rawSurface ), - deleteContext: jest.fn(), + deleteContext: jest.fn((handle: number) => { + deletedHandles.add(handle); + }), ColorSpace: { SRGB: "srgb" }, TRANSPARENT: Float32Array.of(0, 0, 0, 0), }; @@ -93,7 +148,13 @@ beforeAll(() => { configurable: true, get: () => canvasSize.height, }); - HTMLCanvasElement.prototype.getContext = jest.fn(() => null); + HTMLCanvasElement.prototype.getContext = jest.fn(function ( + this: HTMLCanvasElement, + contextId: string + ) { + return contextId === "webgl2" ? webglContextFor(this) : null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; Object.defineProperty(window, "devicePixelRatio", { configurable: true, get: () => display.pixelDensity, @@ -275,4 +336,44 @@ describe("SkiaPictureView.web", () => { view.unmount(); }); + + it("survives its layout effect being re-run on the same canvas", async () => { + const { CanvasKitMock } = createCanvasKitMock(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).CanvasKit = CanvasKitMock; + canvasSize.width = 360; + canvasSize.height = 520; + + // StrictMode re-runs the layout effect against the same , the way + // an Activity reveal does. Effect cleanup is not unmount. + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + + ); + }); + const canvas = container.querySelector("canvas") as HTMLCanvasElement; + await act(async () => { + await Promise.resolve(); + }); + + expect(CanvasKitMock.GetWebGLContext).toHaveBeenCalledTimes(2); + expect(webglContextFor(canvas).lost).toBe(false); + + // The element is still released once it is really gone. + await act(async () => { + root.unmount(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(canvas.isConnected).toBe(false); + expect(webglContextFor(canvas).lost).toBe(true); + + container.remove(); + }); });