From e7c408a28fefe336abf882096d3c555f3876f48c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 15 Aug 2026 12:23:01 +0300 Subject: [PATCH] perf(app): coalesce overlapping autosaves (#531) - Serialize writable-document autosaves and retain only the newest trailing version - Preserve saves requested while export or persistence is in flight - Cover file, storage, retry, and recovery scheduling invariants --- CHANGELOG.md | 1 + src/app/document/autosave/create.ts | 58 ++++++-- src/app/document/io/source.ts | 7 +- .../app/document/autosave/create.test.ts | 124 ++++++++++++++++++ .../app/document/recovery/controller.test.ts | 10 +- 5 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 tests/engine/app/document/autosave/create.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a5bcc8a..679b8ac9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ ### Performance +- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528) - Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500) - Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500) - Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph. diff --git a/src/app/document/autosave/create.ts b/src/app/document/autosave/create.ts index 9a43da051..5c7ae5f37 100644 --- a/src/app/document/autosave/create.ts +++ b/src/app/document/autosave/create.ts @@ -8,7 +8,7 @@ type AutosaveOptions = { state: AutosaveState getSavedVersion: () => number hasWritableSource: () => boolean - saveCurrentDocument: () => Promise + saveCurrentDocument: (version: number) => Promise } export function createAutosave({ @@ -17,20 +17,56 @@ export function createAutosave({ hasWritableSource, saveCurrentDocument }: AutosaveOptions) { + let requestedVersion: number | null = null + let saving: Promise | null = null + let disposed = false + + function canSave(version: number) { + return version > getSavedVersion() && state.autosaveEnabled && hasWritableSource() + } + + async function runSaves() { + while (requestedVersion !== null) { + if (disposed) return + const version = requestedVersion + requestedVersion = null + if (!canSave(version)) continue + await saveCurrentDocument(version) + } + } + + function reportFailure(error: unknown) { + console.warn('Autosave failed:', error) + } + + function requestSave(version: number): Promise { + if (disposed || !canSave(version)) return Promise.resolve() + requestedVersion = Math.max(requestedVersion ?? version, version) + if (!saving) { + saving = runSaves().finally(() => { + saving = null + if (!disposed && requestedVersion !== null) { + void requestSave(requestedVersion).catch(reportFailure) + } + }) + } + return saving + } + const stop = watchDebounced( () => state.sceneVersion, - async (version) => { - if (version === getSavedVersion()) return - if (!state.autosaveEnabled) return - if (!hasWritableSource()) return - try { - await saveCurrentDocument() - } catch (e) { - console.warn('Autosave failed:', e) - } + (version) => { + void requestSave(version).catch(reportFailure) }, { debounce: 3000 } ) - return { disposeAutosave: stop } + return { + requestSave, + disposeAutosave() { + disposed = true + requestedVersion = null + stop() + } + } } diff --git a/src/app/document/io/source.ts b/src/app/document/io/source.ts index 18d2b8d66..f271ba0b0 100644 --- a/src/app/document/io/source.ts +++ b/src/app/document/io/source.ts @@ -82,12 +82,11 @@ export function createDocumentSourceActions({ onDownloadSuccess: (version) => recovery.markProtectedVersion(version) }) - const { disposeAutosave } = createAutosave({ + const autosave = createAutosave({ state, getSavedVersion, hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(), - saveCurrentDocument: async () => { - const version = state.sceneVersion + saveCurrentDocument: async (version) => { const data = await buildFigFile() await writeFile(data, version) } @@ -142,7 +141,7 @@ export function createDocumentSourceActions({ function disposeDocumentIO() { stopWatchingFile() - disposeAutosave() + autosave.disposeAutosave() recovery.disposeRecovery() } diff --git a/tests/engine/app/document/autosave/create.test.ts b/tests/engine/app/document/autosave/create.test.ts new file mode 100644 index 000000000..211300124 --- /dev/null +++ b/tests/engine/app/document/autosave/create.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'bun:test' + +import { reactive } from 'vue' + +import { createDefaultEditorState } from '@open-pencil/core/editor' + +import { createAutosave } from '@/app/document/autosave/create' + +function deferred() { + let resolve: (() => void) | null = null + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve: () => resolve?.() } +} + +function setup(saveCurrentDocument: (version: number) => Promise) { + const state = reactive({ + ...createDefaultEditorState('page-1'), + autosaveEnabled: true + }) + let savedVersion = 0 + let writable = true + const autosave = createAutosave({ + state, + getSavedVersion: () => savedVersion, + hasWritableSource: () => writable, + saveCurrentDocument: async (version) => { + await saveCurrentDocument(version) + savedVersion = version + } + }) + return { + state, + autosave, + setWritable: (value: boolean) => { + writable = value + } + } +} + +describe('document autosave', () => { + test('skips saved versions and documents without writable sources', async () => { + const versions: number[] = [] + const { state, autosave, setWritable } = setup(async (version) => { + versions.push(version) + }) + + await autosave.requestSave(0) + setWritable(false) + state.sceneVersion = 1 + await autosave.requestSave(1) + expect(versions).toEqual([]) + + setWritable(true) + await autosave.requestSave(1) + state.sceneVersion = 0 + await autosave.requestSave(0) + await autosave.requestSave(1) + expect(versions).toEqual([1]) + autosave.disposeAutosave() + }) + + test('coalesces 100 edits during an in-flight save into the latest version', async () => { + const firstSave = deferred() + const started: number[] = [] + const { state, autosave } = setup(async (version) => { + started.push(version) + if (started.length === 1) await firstSave.promise + }) + + state.sceneVersion = 1 + const pending = autosave.requestSave(1) + await Promise.resolve() + for (let version = 2; version <= 101; version++) { + state.sceneVersion = version + void autosave.requestSave(version) + } + firstSave.resolve() + await pending + + expect(started).toEqual([1, 101]) + autosave.disposeAutosave() + }) + + test('retries the current version after a failed save', async () => { + let attempts = 0 + const { state, autosave } = setup(async () => { + attempts++ + if (attempts === 1) throw new Error('write failed') + }) + state.sceneVersion = 1 + + await expect(autosave.requestSave(1)).rejects.toThrow('write failed') + await autosave.requestSave(1) + + expect(attempts).toBe(2) + autosave.disposeAutosave() + }) + + test('preserves a newer requested version when the active save fails', async () => { + const firstSave = deferred() + const started: number[] = [] + const { state, autosave } = setup(async (version) => { + started.push(version) + if (version === 1) { + await firstSave.promise + throw new Error('write failed') + } + }) + + state.sceneVersion = 1 + const failed = autosave.requestSave(1) + await Promise.resolve() + state.sceneVersion = 2 + void autosave.requestSave(2) + firstSave.resolve() + await expect(failed).rejects.toThrow('write failed') + await Promise.resolve() + + expect(started).toEqual([1, 2]) + autosave.disposeAutosave() + }) +}) diff --git a/tests/engine/app/document/recovery/controller.test.ts b/tests/engine/app/document/recovery/controller.test.ts index dd06a906e..22c02f8ad 100644 --- a/tests/engine/app/document/recovery/controller.test.ts +++ b/tests/engine/app/document/recovery/controller.test.ts @@ -58,7 +58,7 @@ describe('document recovery controller', () => { recovery.disposeRecovery() }) - test('coalesces concurrent changes to the latest scene version', async () => { + test('recovery coalesces 100 changes during encoding to the latest scene version', async () => { let release: (() => void) | null = null let calls = 0 const { state, store, recovery } = setup(async () => { @@ -73,8 +73,10 @@ describe('document recovery controller', () => { state.sceneVersion = 1 const pending = recovery.persistNow() await Promise.resolve() - state.sceneVersion = 2 - void recovery.persistNow() + for (let version = 2; version <= 101; version++) { + state.sceneVersion = version + void recovery.persistNow() + } const releaseFirst = () => { if (release) release() } @@ -82,7 +84,7 @@ describe('document recovery controller', () => { await pending expect(calls).toBe(2) - expect((await store.read('recovery-1'))?.sceneVersion).toBe(2) + expect((await store.read('recovery-1'))?.sceneVersion).toBe(101) recovery.disposeRecovery() })