diff --git a/src/lib/admin/autoSaveLifecycle.ts b/src/lib/admin/autoSaveLifecycle.ts new file mode 100644 index 0000000..4111039 --- /dev/null +++ b/src/lib/admin/autoSaveLifecycle.ts @@ -0,0 +1,60 @@ +import { beforeNavigate } from '$app/navigation' +import { onDestroy } from 'svelte' +import type { AutoSaveController } from './autoSave' + +interface AutoSaveLifecycleOptions { + isReady?: () => boolean + onFlushError?: (error: unknown) => void + enableShortcut?: boolean +} + +export function initAutoSaveLifecycle( + controller: AutoSaveController, + options: AutoSaveLifecycleOptions = {} +) { + const { isReady = () => true, onFlushError, enableShortcut = true } = options + + if (typeof window === 'undefined') { + onDestroy(() => controller.destroy()) + return + } + + function handleKeydown(event: KeyboardEvent) { + if (!enableShortcut) return + if (!isReady()) return + const key = event.key.toLowerCase() + const isModifier = event.metaKey || event.ctrlKey + if (!isModifier || key !== 's') return + event.preventDefault() + controller.flush().catch((error) => { + onFlushError?.(error) + }) + } + + if (enableShortcut) { + document.addEventListener('keydown', handleKeydown) + } + + const stopNavigating = beforeNavigate(async (navigation) => { + if (!isReady()) return + navigation.cancel() + try { + await controller.flush() + navigation.retry() + } catch (error) { + onFlushError?.(error) + } + }) + + const stop = () => { + if (enableShortcut) { + document.removeEventListener('keydown', handleKeydown) + } + stopNavigating?.() + controller.destroy() + } + + onDestroy(stop) + + return { stop } +} diff --git a/tests/autoSaveController.test.ts b/tests/autoSaveController.test.ts new file mode 100644 index 0000000..c0cf1ab --- /dev/null +++ b/tests/autoSaveController.test.ts @@ -0,0 +1,99 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { createAutoSaveController } from '../src/lib/admin/autoSave' + +describe('createAutoSaveController', () => { + beforeEach(() => { + if (typeof navigator === 'undefined') { + // @ts-expect-error add minimal navigator shim for tests + global.navigator = { onLine: true } + } + }) + + it('skips save when payload matches primed baseline', async () => { + let value = 0 + let saveCalls = 0 + + const controller = createAutoSaveController<{ value: number }>({ + debounceMs: 5, + getPayload: () => ({ value }), + save: async () => { + saveCalls += 1 + return { value } + } + }) + + controller.prime({ value }) + controller.schedule() + + await wait(15) + assert.equal(saveCalls, 0) + + await controller.flush() + assert.equal(saveCalls, 0) + + controller.destroy() + }) + + it('saves when payload changes and returns to idle after success', async () => { + let value = 0 + let updatedAt = 0 + let saveCalls = 0 + const statuses: string[] = [] + + const controller = createAutoSaveController<{ value: number; updatedAt: number }, { updatedAt: number }>({ + debounceMs: 5, + idleResetMs: 10, + getPayload: () => ({ value, updatedAt }), + save: async (payload) => { + saveCalls += 1 + return { updatedAt: payload.updatedAt + 1 } + }, + onSaved: (response, { prime }) => { + updatedAt = response.updatedAt + prime({ value, updatedAt }) + } + }) + + const unsubscribe = controller.status.subscribe((status) => { + statuses.push(status) + }) + + controller.prime({ value, updatedAt }) + + value = 1 + controller.schedule() + + await wait(15) + assert.equal(saveCalls, 1) + assert.ok(statuses.includes('saving')) + assert.ok(statuses.includes('saved')) + + await wait(20) + assert.equal(statuses.at(-1), 'idle') + + unsubscribe() + controller.destroy() + }) + + it('cancels pending work on destroy', async () => { + let saveCalls = 0 + const controller = createAutoSaveController<{ foo: string }>({ + debounceMs: 20, + getPayload: () => ({ foo: 'bar' }), + save: async () => { + saveCalls += 1 + } + }) + + controller.schedule() + controller.destroy() + + await wait(30) + assert.equal(saveCalls, 0) + }) +}) + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +}