diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d9d77d4..3337f2da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/app/document/recovery/idb.ts b/src/app/document/recovery/idb.ts index 200218b95..1e074b5b4 100644 --- a/src/app/document/recovery/idb.ts +++ b/src/app/document/recovery/idb.ts @@ -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 { + 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 diff --git a/tests/engine/app/document/recovery/store.test.ts b/tests/engine/app/document/recovery/store.test.ts index e2f107374..b3f3e8ddd 100644 --- a/tests/engine/app/document/recovery/store.test.ts +++ b/tests/engine/app/document/recovery/store.test.ts @@ -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)