From 79552129b5b36a97ccca9773fbacce0092d2a5b2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 17:40:34 +0300 Subject: [PATCH] fix(canvas): discard stale zoom fallback pictures Backing installation advances the preview baseline but previously stamped old whole-scene pictures with the new scene version. Zooming outside backing coverage could replay deleted content and hide newly created shapes. Discard mismatched pictures before advancing that baseline, preserving valid pictures. Cover scene, preview, page and font invalidation plus exact visible pixels through zoom reversals in retained and tiled renderers. --- CHANGELOG.md | 1 + .../src/canvas/renderer/retained-backing.ts | 11 ++ .../canvas/scene-picture-freshness.spec.ts | 127 ++++++++++++++++++ .../fresh-scene-picture-openpencil-darwin.png | Bin 0 -> 2058 bytes .../canvas/retained-backing-pixels.test.ts | 28 ++++ 5 files changed, 167 insertions(+) create mode 100644 tests/e2e/canvas/scene-picture-freshness.spec.ts create mode 100644 tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ff9eab1..bbf5e34b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ ### Fixed +- Keep newly created and edited objects visible during zoom instead of replaying outdated scene content. - Keep property fields and paint previews live during editing, and keep rotated selection labels aligned and object edges stable when previews settle. - Keep Undo and Redo commands available as edit history changes, without requiring another scene edit. - Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic. diff --git a/packages/core/src/canvas/renderer/retained-backing.ts b/packages/core/src/canvas/renderer/retained-backing.ts index 46e8d9379..1f53619e0 100644 --- a/packages/core/src/canvas/renderer/retained-backing.ts +++ b/packages/core/src/canvas/renderer/retained-backing.ts @@ -400,6 +400,17 @@ function installSceneBackingImage( fontGeneration: r.fontGeneration, ...sceneBackingMetrics(backing) } + // Advancing the preview baseline must not relabel an older whole-scene + // picture as current: navigation can still fall back to that picture. + if ( + r.scenePictureVersion !== sceneVersion || + r.scenePicturePositionPreviewVersion !== positionPreviewVersion || + r.scenePicturePageId !== r.pageId || + r.scenePictureFontGeneration !== r.fontGeneration + ) { + r.scenePicture?.delete() + r.scenePicture = null + } r.scenePictureVersion = sceneVersion r.scenePicturePositionPreviewVersion = positionPreviewVersion r.scenePicturePageId = r.pageId diff --git a/tests/e2e/canvas/scene-picture-freshness.spec.ts b/tests/e2e/canvas/scene-picture-freshness.spec.ts new file mode 100644 index 000000000..2877d43d2 --- /dev/null +++ b/tests/e2e/canvas/scene-picture-freshness.spec.ts @@ -0,0 +1,127 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '#tests/e2e/fixtures' +import { expectDefined } from '#tests/helpers/assert' +import { CanvasHelper } from '#tests/helpers/canvas' + +async function observeScenePixels(page: Page) { + return page.evaluateHandle(() => { + const store = window.openPencil?.getStore?.() + const renderer = store?.canvasRenderers.find((r) => r.tracksSceneSettlement) + const node = store?.getSelectedNodes()[0] + const scene = document.querySelector('[data-test-id="scene-canvas-element"]') + if (!store || !renderer || !node || !scene) throw new Error('Scene unavailable') + const copy = document.createElement('canvas') + copy.width = copy.height = 1 + const context = copy.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Readback unavailable') + const surface = renderer.surface + const flush = surface.flush + const samples: { zoom: number; pixel: number[] }[] = [] + surface.flush = (...args) => { + const result = flush.apply(surface, args) + const position = store.graph.getAbsolutePosition(node.id) + const x = Math.floor( + (renderer.panX + (position.x + node.width / 2) * renderer.zoom) * renderer.dpr + ) + const y = Math.floor( + (renderer.panY + (position.y + node.height / 2) * renderer.zoom) * renderer.dpr + ) + context.clearRect(0, 0, 1, 1) + context.drawImage(scene, x, y, 1, 1, 0, 0, 1, 1) + samples.push({ + zoom: renderer.zoom, + pixel: Array.from(context.getImageData(0, 0, 1, 1).data) + }) + return result + } + return { + samples: () => samples, + dispose() { + surface.flush = flush + copy.width = 0 + } + } + }) +} + +test.use({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 }) + +for (const renderer of ['retained', 'tiled'] as const) { + test(`${renderer}: zoom fallback does not resurrect a picture from before creation`, async ({ + page + }) => { + await page.goto(`/?test&no-rulers&renderer=${renderer}&navigation-benchmark`) + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + await page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('Editor unavailable') + const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, { + x: 300, + y: 250, + width: 220, + height: 140, + fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }] + }) + store.select([node.id]) + store.requestRender() + }) + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds') + const x = box.x + 300 + const y = box.y + 250 + await page.mouse.move(x + 110, y + 70) + await page.keyboard.down('Control') + try { + // Leave backing coverage to record a whole-scene fallback, then return. + await page.mouse.wheel(0, 240) + await canvas.waitForRender() + await page.mouse.wheel(0, -240) + } finally { + await page.keyboard.up('Control') + } + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + expect( + await page.evaluate(() => + window.openPencil + ?.getStore?.() + .canvasRenderers.some((r) => r.tracksSceneSettlement && r.scenePicture !== null) + ) + ).toBe(true) + await page.keyboard.press('Backspace') + await page.keyboard.press('r') + await page.mouse.move(x, y) + await page.mouse.down() + await page.mouse.move(x + 220, y + 140) + const probe = await observeScenePixels(page) + try { + await page.mouse.up() + await page.mouse.move(x + 110, y + 70) + await page.keyboard.down('Control') + for (const delta of [60, 60, 60, -180, 180, -180]) { + await page.mouse.wheel(0, delta) + await canvas.waitForRender() + } + await page.keyboard.up('Control') + await page.evaluate(() => window.openPencil?.test?.navigation?.waitForSettlement()) + const samples = await probe.evaluate((p) => p.samples()) + expect(samples.length).toBeGreaterThan(4) + expect(samples.some((sample) => sample.zoom < 0.3)).toBe(true) + for (const sample of samples) { + expect(sample.pixel, JSON.stringify(sample)).toEqual([212, 212, 212, 255]) + } + await expect(page).toHaveScreenshot('fresh-scene-picture.png', { + clip: { x: x - 20, y: y - 20, width: 260, height: 200 }, + maxDiffPixels: 0, + threshold: 0 + }) + canvas.assertNoErrors() + } finally { + await page.keyboard.up('Control') + await page.mouse.up() + await probe.evaluate((p) => p.dispose()) + await probe.dispose() + } + }) +} diff --git a/tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png b/tests/e2e/canvas/scene-picture-freshness.spec.ts-snapshots/fresh-scene-picture-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..c84cd3858a31f56c7ccd6585ff52282ffad62de1 GIT binary patch literal 2058 zcmd5-c~H{_8vQ9MXd!@TVHXg)fw0Kh$R!N{DThlzgbua@xlK4!AVPpZASA9-s6vQ> z+*i>8h9gL;B1ecC4g(TEkfV@`D;!BU1B5`>xb6PC`)~jG=FNOF@0)q=ee>>p>*1ny z?61cF08n%N#@P!14jfY8cPa-J^Iz5~5CBl7yE;33msld4Bwn9P(C=RDpCzOWmBb^8 zMzSDFQx6~{lKq&0sj7v|5t-8on-_0PR2>d!Zl+&}dpaMa<)}simqx$%g3*~|Su4vzoY|Y>EmtUisF%4GI0u%Zr z95bJz)vOj~3X@{iDt+xe$9lweE4x(zmB~v@L!z=tCn0~ijLK#(d?}tE?%u+wcA?|L zlAblg4Z zRC3p#$STAAL(qilKL<*6K$%@+y+sz4!!HO?zgXB>S~_NSb$w1w`w!_tk0A}E(@& z#R)6;Q4>&(O{fumJ)zQZQ1Y`VTL6Hqcv#(p_(_^k}dzJ2f^C%-72zZ9k9gE^l7z4j=VH2zxIiZ}Iq) z=NZlPz2rRvk@i6<=e8QlXTgP$VZ3>;v}=lCQMkp;j;wR$zYNcE}^`&RAFO_U(zhEGy#T`W9$!w?DraA(nod zB;$R&HJ%4g*GB#*UTBsC?N-Wz>p%MY)V>I47R_~77+;dUC<+kXk3#>(1gl3jrN7Eu z8AvH4vo$S=i@Nmo?S}opy_2bIYc;*nQ@ewx2vY9Z1Q0Y6+Jb<7G!RcTb+)hFdygVQ zUk11uBOCmWakqB{&RdISTFKNDnZ~&Q%!Ve zXmPph&E1h)fd`^2seOY1ZJRhBUT!)*S5;~&#V;6qR{3nMT~eIETrv>6-)lhTla>tV z;O)H}iBiocop#Y))nVAbs9B%9VwXpt7>f1-AGoa(=kudJwDTQ;yD2 zJlOX728&G$wN?hUN#BM6OI4P?5{O7T0RM*4|7pJeRwGUg0QUDi2c#@TqaXQH { + const { graph, pageId } = createFixture() + const renderer = createRenderer(pageId) + try { + render(renderer, graph, new Set(), {}, 1, 'full') + const picture = expectDefined(renderer.scenePicture, 'whole-scene picture') + const deletion = spyOn(picture, 'delete') + try { + if (scope === 'scene') renderer.scenePictureVersion-- + if (scope === 'preview') renderer.scenePicturePositionPreviewVersion-- + if (scope === 'page') renderer.scenePicturePageId = null + if (scope === 'font') renderer.scenePictureFontGeneration-- + expect(renderSceneBacking(renderer, renderer.surface.getCanvas(), graph, 1)).not.toBe(false) + expect(renderer.scenePicture).toBe(scope === 'matching' ? picture : null) + expect(deletion).toHaveBeenCalledTimes(scope === 'matching' ? 0 : 1) + expect(renderer.scenePictureVersion).toBe(1) + expect(renderer.scenePicturePositionPreviewVersion).toBe(graph.positionPreviewVersion) + } finally { + deletion.mockRestore() + } + } finally { + renderer.destroy() + } + }) +} + function pixels(renderer: SkiaRenderer) { renderer.surface.flush() const image = renderer.surface.makeImageSnapshot()