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
This commit is contained in:
Danila Poyarkov 2026-08-13 21:39:35 +03:00 committed by GitHub
parent b5a0a9ec54
commit 631fc25ee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 82 additions and 19 deletions

View file

@ -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)

View file

@ -65,9 +65,7 @@ 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 = runWrites(generation).finally(() => {
writing = null
})
}
@ -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 }
)

View file

@ -36,7 +36,6 @@ function generateTabId(): string {
}
const tabsRef = shallowRef<Tab[]>([])
const pendingRecoveryDeletions = new Set<Promise<void>>()
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<void> {
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<void> {
}
export async function prepareForReload(): Promise<void> {
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 {

View file

@ -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
})

View file

@ -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<IDBDatabase>((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<number>((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()
})

View file

@ -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