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
|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
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()
|
||||
})
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue