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
This commit is contained in:
Danila Poyarkov 2026-08-15 12:23:01 +03:00 committed by GitHub
parent b0ebe91ed6
commit e7c408a28f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 181 additions and 19 deletions

View file

@ -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.

View file

@ -8,7 +8,7 @@ type AutosaveOptions = {
state: AutosaveState
getSavedVersion: () => number
hasWritableSource: () => boolean
saveCurrentDocument: () => Promise<void>
saveCurrentDocument: (version: number) => Promise<void>
}
export function createAutosave({
@ -17,20 +17,56 @@ export function createAutosave({
hasWritableSource,
saveCurrentDocument
}: AutosaveOptions) {
let requestedVersion: number | null = null
let saving: Promise<void> | 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<void> {
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()
}
}
}

View file

@ -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()
}

View file

@ -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<void>((done) => {
resolve = done
})
return { promise, resolve: () => resolve?.() }
}
function setup(saveCurrentDocument: (version: number) => Promise<void>) {
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()
})
})

View file

@ -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()
})