From 82ef49aa9e640d7d1064aec198076d9d3cb2d4eb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 5 Aug 2026 18:54:18 +0300 Subject: [PATCH 1/2] fix(canvas): bound retained backing allocations - Scale retained margins to a fixed device-pixel budget on large viewports - Fall back to direct scene rendering when CanvasKit rejects an offscreen surface - Cover the reported wide HiDPI geometry without coupling rendering to a WebGL context --- CHANGELOG.md | 1 + .../src/canvas/renderer/retained-backing.ts | 34 +++++++++---- .../render/canvas/retained-backing.test.ts | 51 ++++++++++++++++++- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a612a28..e1a2a7d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ ### Fixed +- Keep the canvas rendering when hiding or showing the UI on wide HiDPI viewports by bounding retained scene backing allocations and falling back to direct rendering when CanvasKit rejects an offscreen surface. - Match Figma auto-layout spacing, padding, min/max constraints, scalar variable bindings, CanvasKit-shaped generated text, imported text bounds, and nested instance geometry more closely. - Match Figma Plugin API vector path and network editing, including bounds, transforms, winding rules, region fills, validation, and handle mirroring. (#444) - Let AI and MCP tools create arbitrary vectors from SVG path data, validating input without leaving blank layers behind. (#440) diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 862839ede..d0bce799a 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -10,6 +10,7 @@ import type { RenderLayer } from './pipeline' const now = typeof performance !== 'undefined' ? () => performance.now() : () => 0 const SCENE_BACKING_SCALE = 3 +const MAX_SCENE_BACKING_DEVICE_PIXELS = 16_000_000 const FRAME_BUDGET_60HZ_MS = 1000 / 60 const MIN_SCENE_BACKING_IDLE_FRAMES = 2 const MAX_SCENE_BACKING_IDLE_FRAMES = 18 @@ -153,9 +154,20 @@ function drawSceneBacking( return true } +function sceneBackingScale(r: SkiaRenderer): number { + const viewportDevicePixels = r.viewportWidth * r.viewportHeight * r.dpr * r.dpr + if (viewportDevicePixels <= 0) return 1 + return clamp( + Math.sqrt(MAX_SCENE_BACKING_DEVICE_PIXELS / viewportDevicePixels), + 1, + SCENE_BACKING_SCALE + ) +} + function sceneBackingGeometry(r: SkiaRenderer) { - const marginX = r.viewportWidth * ((SCENE_BACKING_SCALE - 1) / 2) - const marginY = r.viewportHeight * ((SCENE_BACKING_SCALE - 1) / 2) + const backingScale = sceneBackingScale(r) + const marginX = r.viewportWidth * ((backingScale - 1) / 2) + const marginY = r.viewportHeight * ((backingScale - 1) / 2) const width = Math.max(1, Math.ceil(r.viewportWidth + marginX * 2)) const height = Math.max(1, Math.ceil(r.viewportHeight + marginY * 2)) const backingPanX = r.panX + marginX @@ -175,13 +187,17 @@ function sceneBackingGeometry(r: SkiaRenderer) { } function createSceneBackingSurface(r: SkiaRenderer, width: number, height: number): Surface | null { - 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 - }) + 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 null + } } function ensureSubtreePictureCacheScope( diff --git a/tests/engine/render/canvas/retained-backing.test.ts b/tests/engine/render/canvas/retained-backing.test.ts index 05c3dcadc..0d93f6cad 100644 --- a/tests/engine/render/canvas/retained-backing.test.ts +++ b/tests/engine/render/canvas/retained-backing.test.ts @@ -1,13 +1,13 @@ import { expect, mock, test } from 'bun:test' -import type { Canvas, Image as CKImage, Surface } from 'canvaskit-wasm' +import type { Canvas, Image as CKImage, ImageInfo, Surface } from 'canvaskit-wasm' import type { SceneGraph } from '@open-pencil/scene-graph' import type { SkiaRenderer } from '#core/canvas/renderer' import { renderSceneBacking } from '#core/canvas/renderer/retained-backing' -function createRenderer(surfaceFactory: () => Surface | null) { +function createRenderer(surfaceFactory: (info: ImageInfo) => Surface | null) { const renderer: Partial = { ck: { AlphaType: { Premul: 'Premul' }, @@ -88,6 +88,53 @@ test('retained scene backing falls back when CanvasKit cannot create an offscree expect(r.sceneBacking).toBeNull() }) +test('retained scene backing bounds wide HiDPI allocations without depending on GPU limits', () => { + const requests: ImageInfo[] = [] + const r = createRenderer((info) => { + requests.push(info) + return null + }) + r.viewportWidth = 2998 + r.viewportHeight = 1490 + r.dpr = 2 + + expect(renderSceneBacking(r, createCanvas(), createGraph(), 1)).toBe(false) + expect(requests).toHaveLength(1) + const request = requests[0] + expect(request).toBeDefined() + const viewportPixels = Math.ceil(r.viewportWidth * r.dpr) * Math.ceil(r.viewportHeight * r.dpr) + expect((request?.width ?? 0) * (request?.height ?? 0)).toBeLessThanOrEqual( + Math.max(16_010_000, viewportPixels) + ) + expect(request?.width).toBe(Math.ceil(r.viewportWidth * r.dpr)) +}) + +test('retained scene backing preserves the full margin when it fits the allocation budget', () => { + const requests: ImageInfo[] = [] + const r = createRenderer((info) => { + requests.push(info) + return null + }) + r.viewportWidth = 800 + r.viewportHeight = 600 + r.dpr = 1 + + renderSceneBacking(r, createCanvas(), createGraph(), 1) + + expect(requests[0]).toMatchObject({ width: 2400, height: 1800 }) +}) + +test('retained scene backing falls back when CanvasKit throws during allocation', () => { + const r = createRenderer(() => { + throw new TypeError("Cannot set properties of null (setting 'be')") + }) + const canvas = createCanvas() + + expect(() => renderSceneBacking(r, canvas, createGraph(), 1)).not.toThrow() + expect(r.sceneBacking).toBeNull() + expect(canvas.drawImageRectOptions).not.toHaveBeenCalled() +}) + test('retained scene backing filters cross-zoom previews instead of falling back to live rendering', () => { const r = createRenderer(() => null) r.zoom = 1 From afc5305e8ce550bb3edb86148b136df70750d58b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 5 Aug 2026 19:10:41 +0300 Subject: [PATCH 2/2] fix(canvas): report retained backing failures - Warn once with the rejected surface dimensions and original CanvasKit error - Disable repeated cache allocation attempts until the renderer surface is replaced - Keep direct scene rendering as the visible fallback --- packages/core/src/canvas/renderer.ts | 2 ++ .../src/canvas/renderer/retained-backing.ts | 24 ++++++++++++------- .../render/canvas/retained-backing.test.ts | 19 ++++++++++++--- 3 files changed, 34 insertions(+), 11 deletions(-) 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', () => {