fix(recovery): store snapshots of any size (#966)

* fix(recovery): snapshot a document only once it has unsaved edits

Opening a document lays it out, which bumps the scene version, so crash
recovery wrote a full .fig snapshot of every document opened from a file
without a writable source, edited or not. For the 401 MiB file from #924
that copied the whole archive a few seconds after opening and killed the
tab. Recovery now asks the document's change tracking, which ignores
layout, whether anything is unsaved.

* fix(recovery): store snapshots as Blobs so large documents fit

Chromium refuses a single IndexedDB value over 127 MiB, so a snapshot of
any larger document failed and recovery fell back to memory for the
rest of the session, holding another copy of the document. A Blob is
kept as a file at any size: a 421 MB snapshot now writes in IndexedDB.
Writes also no longer copy the archive first, and snapshots stored as
byte arrays by earlier versions still read back.

* test(recovery): drop the scene version from the large-buffer snapshot
This commit is contained in:
Danila Poyarkov 2026-10-07 21:30:07 +00:00 committed by GitHub
parent 4254204685
commit 6e68366bc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 39 additions and 5 deletions

View file

@ -125,6 +125,7 @@
### Fixed
- Store crash recovery snapshots of any size. Snapshots of documents over 127 MiB failed to save to IndexedDB and stayed in memory for the rest of the session.
- Keep an opened `.fig` file saved until it is edited. Laying out its first page, which recomputes auto-layout sizes and positions, and showing another page for the first time, which loads its layers from the file, marked it unsaved, so closing it asked to save changes nobody made.
- Run crash recovery and autosave after edits, not whenever the canvas redraws. Opening a document, laying out a page, or loading a font started a recovery snapshot or an autosave, which encoded the whole document again once another page had loaded. In Safari, where every opened file gets recovery snapshots, a large page froze the browser for minutes after it first appeared.
- Open image-heavy `.fig` files without the canvas running out of memory (#924). Decoded images stay within a fixed budget, and documents with many large images draw previews sized to the view, decoded in the background a few at a time, while exports keep the full images.

View file

@ -27,10 +27,30 @@ interface RecoveryDatabase extends DBSchema {
}
fig: {
key: string
value: Uint8Array
value: StoredFig
}
}
/**
* A snapshot as IndexedDB stores it. Chromium refuses a single value over 127 MiB, which a
* document's archive passes easily, but keeps a Blob as a file at any size, and faster.
* Snapshots from earlier versions are byte arrays.
*/
type StoredFig = Blob | Uint8Array
/** A Blob of the snapshot without copying it; bytes over a shared buffer are copied first. */
function figBlob(bytes: Uint8Array): Blob {
const plain =
bytes.buffer instanceof ArrayBuffer
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
: bytes.slice()
return new Blob([plain])
}
async function figBytesOf(stored: StoredFig): Promise<Uint8Array> {
return stored instanceof Blob ? new Uint8Array(await stored.arrayBuffer()) : stored
}
export function createIdbRecoveryStore(): RecoveryStore {
const database = openAppDatabase(recoveryDatabase)
@ -43,13 +63,13 @@ export function createIdbRecoveryStore(): RecoveryStore {
async read(id: string) {
const db = await database
const transaction = db.transaction(['meta', 'fig'])
const [metadata, figBytes] = await Promise.all([
const [metadata, stored] = await Promise.all([
transaction.objectStore('meta').get(id),
transaction.objectStore('fig').get(id)
])
await transaction.done
return metadata && figBytes
? ({ ...metadata, figBytes: Uint8Array.from(figBytes) } satisfies RecoverySnapshot)
return metadata && stored
? ({ ...metadata, figBytes: await figBytesOf(stored) } satisfies RecoverySnapshot)
: null
},
@ -65,7 +85,7 @@ export function createIdbRecoveryStore(): RecoveryStore {
}
await Promise.all([
transaction.objectStore('meta').put(metadata),
transaction.objectStore('fig').put(Uint8Array.from(input.figBytes), input.id),
transaction.objectStore('fig').put(figBlob(input.figBytes), input.id),
transaction.done
])
return metadata

View file

@ -46,6 +46,19 @@ describe('document recovery store', () => {
expect(await store.read('recovery-1')).toBeNull()
})
test('stores a view into a larger buffer as its own bytes', async () => {
const store = createIdbRecoveryStore()
const buffer = new Uint8Array([9, 9, 1, 2, 3, 9])
await store.write({
id: 'recovery-1',
documentName: 'Agent draft',
figBytes: buffer.subarray(2, 5)
})
const stored = await store.read('recovery-1')
expect(stored?.figBytes).toEqual(new Uint8Array([1, 2, 3]))
expect(stored?.figBytes.buffer.byteLength).toBe(3)
})
test('memory store owns input and output bytes', async () => {
const store = createMemoryRecoveryStore()
const input = new Uint8Array(bytes)