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:
parent
b5a0a9ec54
commit
631fc25ee6
|
|
@ -15,6 +15,7 @@
|
||||||
|
|
||||||
### Fixed
|
### 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)
|
- 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)
|
- Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397)
|
||||||
- Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397)
|
- Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397)
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,7 @@ export function createDocumentRecovery({
|
||||||
if (requestedVersion === protectedVersion) return
|
if (requestedVersion === protectedVersion) return
|
||||||
if (!writing) {
|
if (!writing) {
|
||||||
const generation = lifecycleGeneration
|
const generation = lifecycleGeneration
|
||||||
writing = runWrites(generation)
|
writing = runWrites(generation).finally(() => {
|
||||||
.catch((error) => console.warn('[Recovery] Snapshot failed:', error))
|
|
||||||
.finally(() => {
|
|
||||||
writing = null
|
writing = null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +75,7 @@ export function createDocumentRecovery({
|
||||||
const stop: WatchHandle = watchDebounced(
|
const stop: WatchHandle = watchDebounced(
|
||||||
() => state.sceneVersion,
|
() => state.sceneVersion,
|
||||||
() => {
|
() => {
|
||||||
void persistNow()
|
void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error))
|
||||||
},
|
},
|
||||||
{ debounce: 3000, maxWait: 10000 }
|
{ debounce: 3000, maxWait: 10000 }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ function generateTabId(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabsRef = shallowRef<Tab[]>([])
|
const tabsRef = shallowRef<Tab[]>([])
|
||||||
const pendingRecoveryDeletions = new Set<Promise<void>>()
|
|
||||||
const activeTabId = shallowRef('')
|
const activeTabId = shallowRef('')
|
||||||
|
|
||||||
export const activeTab = computed(() => tabsRef.value.find((t) => t.id === activeTabId.value))
|
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 closingTab = tabsRef.value[idx]
|
||||||
const wasActive = activeTabId.value === tabId
|
const wasActive = activeTabId.value === tabId
|
||||||
const deletion = closingTab.store.discardRecovery()
|
await closingTab.store.persistRecoveryNow()
|
||||||
pendingRecoveryDeletions.add(deletion)
|
|
||||||
try {
|
|
||||||
await deletion
|
|
||||||
} finally {
|
|
||||||
pendingRecoveryDeletions.delete(deletion)
|
|
||||||
}
|
|
||||||
closingTab.store.dispose()
|
closingTab.store.dispose()
|
||||||
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
|
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> {
|
export async function prepareForReload(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow()))
|
||||||
...pendingRecoveryDeletions,
|
|
||||||
...tabsRef.value.map((tab) => tab.store.persistRecoveryNow())
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tabCount(): number {
|
export function tabCount(): number {
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,9 @@ test('autosave triggers after scene changes with a file handle', async () => {
|
||||||
expect(writeHappened).toBe(true)
|
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({
|
const context = await browser.newContext({
|
||||||
|
baseURL,
|
||||||
viewport: { width: 1280, height: 800 },
|
viewport: { width: 1280, height: 800 },
|
||||||
deviceScaleFactor: 2
|
deviceScaleFactor: 2
|
||||||
})
|
})
|
||||||
|
|
|
||||||
47
tests/e2e/recovery.spec.ts
Normal file
47
tests/e2e/recovery.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
|
@ -86,6 +86,32 @@ describe('document recovery controller', () => {
|
||||||
recovery.disposeRecovery()
|
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 () => {
|
test('successful save removes recovery data', async () => {
|
||||||
const { state, store, recovery } = setup()
|
const { state, store, recovery } = setup()
|
||||||
state.sceneVersion = 1
|
state.sceneVersion = 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue