From 631fc25ee6e314ac27d616a27b6f393e1aac0ce5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 21:39:35 +0300 Subject: [PATCH] fix(app): retain recovery after closing unsaved tabs (#505) * fix(app): retain recovery after closing unsaved tabs - Persist source-less tab snapshots before disposing editors - Keep retained snapshots available for startup restore or explicit discard - Cover close, reload, and restore behavior in Playwright * fix(app): surface recovery persistence failures - Propagate explicit close and reload snapshot failures - Keep background debounce failures logged without unhandled rejections - Exercise close-time persistence in recovery coverage * test(app): cover recovery persistence retry * docs: restore recovery retention note --- CHANGELOG.md | 1 + src/app/document/recovery/controller.ts | 10 ++-- src/app/tabs/index.ts | 14 +----- tests/e2e/autosave.spec.ts | 3 +- tests/e2e/recovery.spec.ts | 47 +++++++++++++++++++ .../app/document/recovery/controller.test.ts | 26 ++++++++++ 6 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/recovery.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049eac36..09e890946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### Fixed +- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior. - Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397) - Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397) - Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397) diff --git a/src/app/document/recovery/controller.ts b/src/app/document/recovery/controller.ts index 3c36cb8b3..005ad9bd4 100644 --- a/src/app/document/recovery/controller.ts +++ b/src/app/document/recovery/controller.ts @@ -65,11 +65,9 @@ export function createDocumentRecovery({ if (requestedVersion === protectedVersion) return if (!writing) { const generation = lifecycleGeneration - writing = runWrites(generation) - .catch((error) => console.warn('[Recovery] Snapshot failed:', error)) - .finally(() => { - writing = null - }) + writing = runWrites(generation).finally(() => { + writing = null + }) } await writing } @@ -77,7 +75,7 @@ export function createDocumentRecovery({ const stop: WatchHandle = watchDebounced( () => state.sceneVersion, () => { - void persistNow() + void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error)) }, { debounce: 3000, maxWait: 10000 } ) diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 56a6d2243..cd6953b7d 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -36,7 +36,6 @@ function generateTabId(): string { } const tabsRef = shallowRef([]) -const pendingRecoveryDeletions = new Set>() const activeTabId = shallowRef('') export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value)) @@ -98,13 +97,7 @@ export async function closeTab(tabId: string): Promise { const closingTab = tabsRef.value[idx] const wasActive = activeTabId.value === tabId - const deletion = closingTab.store.discardRecovery() - pendingRecoveryDeletions.add(deletion) - try { - await deletion - } finally { - pendingRecoveryDeletions.delete(deletion) - } + await closingTab.store.persistRecoveryNow() closingTab.store.dispose() tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId) @@ -304,10 +297,7 @@ export async function restoreRecoverySnapshot(id: string): Promise { } export async function prepareForReload(): Promise { - await Promise.all([ - ...pendingRecoveryDeletions, - ...tabsRef.value.map((tab) => tab.store.persistRecoveryNow()) - ]) + await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow())) } export function tabCount(): number { diff --git a/tests/e2e/autosave.spec.ts b/tests/e2e/autosave.spec.ts index 431a3af34..ad933514f 100644 --- a/tests/e2e/autosave.spec.ts +++ b/tests/e2e/autosave.spec.ts @@ -67,8 +67,9 @@ test('autosave triggers after scene changes with a file handle', async () => { expect(writeHappened).toBe(true) }) -test('no autosave without file handle', async ({ browser }) => { +test('no autosave without file handle', async ({ browser, baseURL }) => { const context = await browser.newContext({ + baseURL, viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }) diff --git a/tests/e2e/recovery.spec.ts b/tests/e2e/recovery.spec.ts new file mode 100644 index 000000000..1155c87a8 --- /dev/null +++ b/tests/e2e/recovery.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '#tests/e2e/fixtures' +import { CanvasHelper } from '#tests/helpers/canvas' + +test('keeps an unsaved document recoverable after its tab closes', async ({ browser, baseURL }) => { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + await page.goto('/') + const canvas = new CanvasHelper(page) + await canvas.waitForInit() + + await page.evaluate(async () => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const id = store.createShape('RECTANGLE', 120, 120, 240, 140) + await store.persistRecoveryNow() + store.updateNode(id, { name: 'Retained recovery rectangle' }) + }) + + await page.keyboard.press('ControlOrMeta+t') + await expect(page.getByRole('button', { name: 'New tab' })).toBeVisible() + await page.getByTestId('tabbar-tab').first().getByTestId('tabbar-close').click() + await expect(page.getByRole('button', { name: 'New tab' })).toBeHidden() + await expect + .poll(() => + page.evaluate(async () => { + const request = indexedDB.open('open-pencil-recovery') + const database = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + const transaction = database.transaction('meta') + const countRequest = transaction.objectStore('meta').count() + return new Promise((resolve, reject) => { + countRequest.onsuccess = () => resolve(countRequest.result) + countRequest.onerror = () => reject(countRequest.error) + }) + }) + ) + .toBe(1) + + await page.reload() + await expect(page.getByRole('alertdialog', { name: 'Recover unsaved work' })).toBeVisible() + await page.getByRole('button', { name: 'Restore' }).click() + await expect(page.getByText('Retained recovery rectangle')).toBeVisible() + + await context.close() +}) diff --git a/tests/engine/app/document/recovery/controller.test.ts b/tests/engine/app/document/recovery/controller.test.ts index 572a072b0..dd06a906e 100644 --- a/tests/engine/app/document/recovery/controller.test.ts +++ b/tests/engine/app/document/recovery/controller.test.ts @@ -86,6 +86,32 @@ describe('document recovery controller', () => { recovery.disposeRecovery() }) + test('propagates persistence failures to close and reload callers', async () => { + const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Draft' }) + const store = createMemoryRecoveryStore() + const memoryWrite = store.write.bind(store) + let writeAttempts = 0 + store.write = async (input) => { + writeAttempts++ + if (writeAttempts === 1) throw new Error('recovery storage unavailable') + return memoryWrite(input) + } + const recovery = createDocumentRecovery({ + state, + store, + recoveryId: 'recovery-1', + hasWritableSource: () => false, + buildFigFile: () => new Uint8Array([1]) + }) + state.sceneVersion = 1 + + await expect(recovery.persistNow()).rejects.toThrow('recovery storage unavailable') + await recovery.persistNow() + expect(writeAttempts).toBe(2) + expect((await store.read('recovery-1'))?.sceneVersion).toBe(1) + recovery.disposeRecovery() + }) + test('successful save removes recovery data', async () => { const { state, store, recovery } = setup() state.sceneVersion = 1