diff --git a/packages/core/src/canvas/renderer.ts b/packages/core/src/canvas/renderer.ts index 8b7b3947d..96b32ccf2 100644 --- a/packages/core/src/canvas/renderer.ts +++ b/packages/core/src/canvas/renderer.ts @@ -122,6 +122,7 @@ export class SkiaRenderer { } | null = null sceneBackingPreviewUntil = 0 sceneBackingNeedsCrispRender = false + sceneBackingAllocationFailed = false sceneBackingBuild: { surface: Surface graph: SceneGraph @@ -451,6 +452,7 @@ export class SkiaRenderer { replaceSurface(surface: Surface): void { this.surface.delete() this.surface = surface + this.sceneBackingAllocationFailed = false this.invalidateScenePicture() } diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index d0bce799a..14aba8825 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -187,15 +187,22 @@ function sceneBackingGeometry(r: SkiaRenderer) { } function createSceneBackingSurface(r: SkiaRenderer, width: number, height: number): Surface | null { + if (r.sceneBackingAllocationFailed) return null + const info = { + width: Math.ceil(width * r.dpr), + height: Math.ceil(height * r.dpr), + colorType: r.ck.ColorType.RGBA_8888, + alphaType: r.ck.AlphaType.Premul, + colorSpace: r.ck.ColorSpace.SRGB + } try { - return r.surface.makeSurface({ - width: Math.ceil(width * r.dpr), - height: Math.ceil(height * r.dpr), - colorType: r.ck.ColorType.RGBA_8888, - alphaType: r.ck.AlphaType.Premul, - colorSpace: r.ck.ColorSpace.SRGB - }) - } catch { + return r.surface.makeSurface(info) + } catch (error) { + r.sceneBackingAllocationFailed = true + console.warn( + `Disabling retained scene backing after CanvasKit failed to allocate ${info.width}×${info.height}`, + error + ) return null } } @@ -452,6 +459,7 @@ export function renderSceneBacking( graph: SceneGraph, sceneVersion: number ): boolean { + if (r.sceneBackingAllocationFailed) return false const positionPreviewVersion = graph.positionPreviewVersion const allowStaleZoom = now() < r.sceneBackingPreviewUntil const hasCoverage = backingCoverageContainsLiveViewport( diff --git a/tests/engine/render/canvas/retained-backing.test.ts b/tests/engine/render/canvas/retained-backing.test.ts index 0d93f6cad..597a9dc4d 100644 --- a/tests/engine/render/canvas/retained-backing.test.ts +++ b/tests/engine/render/canvas/retained-backing.test.ts @@ -1,4 +1,4 @@ -import { expect, mock, test } from 'bun:test' +import { expect, mock, spyOn, test } from 'bun:test' import type { Canvas, Image as CKImage, ImageInfo, Surface } from 'canvaskit-wasm' @@ -39,6 +39,7 @@ function createRenderer(surfaceFactory: (info: ImageInfo) => Surface | null) { pageId: 'page', sceneBacking: null, sceneBackingBuild: null, + sceneBackingAllocationFailed: false, sceneBackingNeedsCrispRender: false, sceneBackingPreviewUntil: 0, sceneBackingAverageRecordMs: 40, @@ -124,15 +125,27 @@ test('retained scene backing preserves the full margin when it fits the allocati expect(requests[0]).toMatchObject({ width: 2400, height: 1800 }) }) -test('retained scene backing falls back when CanvasKit throws during allocation', () => { +test('retained scene backing reports a throwing allocation and disables further attempts', () => { + const error = new TypeError("Cannot set properties of null (setting 'be')") const r = createRenderer(() => { - throw new TypeError("Cannot set properties of null (setting 'be')") + throw error }) const canvas = createCanvas() + const warn = spyOn(console, 'warn').mockImplementation(() => undefined) expect(() => renderSceneBacking(r, canvas, createGraph(), 1)).not.toThrow() + expect(renderSceneBacking(r, canvas, createGraph(), 1)).toBe(false) + + expect(r.sceneBackingAllocationFailed).toBe(true) + expect(r.surface.makeSurface).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + 'Disabling retained scene backing after CanvasKit failed to allocate 300×300', + error + ) expect(r.sceneBacking).toBeNull() expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() + warn.mockRestore() }) test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => {