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(); + }); });