jedmund-svelte/src/lib/admin/autoSave.svelte.ts
Justin Edmund d72d32001e fix: Address critical issues in admin form refactor
Phase 1: Critical Type Fixes
- Fix useDraftRecovery type signature to accept () => string | null
  (was incorrectly typed as string | null)
- Add TResponse generic parameter to AutoSaveStore interface
- Update all call sites to use function for draftKey reactivity

Phase 2: Robustness Improvements
- Remove non-null assertions in useFormGuards
- Capture autoSave in closures to prevent potential null access
- Make useFormGuards generic to accept any AutoSaveStore types

Documentation & Code Quality
- Document AlbumForm autosave initialization order
- Add comments explaining void operator usage for reactivity
- Fix eslint error: prefix unused _TResponse with underscore

These fixes address the critical issues found in PR review:
1. Type mismatch causing TypeScript errors
2. Non-null assertions that could cause crashes
3. Missing documentation for complex initialization patterns
2025-11-24 08:03:43 -08:00

143 lines
3.6 KiB
TypeScript

export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
debounceMs?: number
idleResetMs?: number
getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
}
export interface AutoSaveStore<TPayload, _TResponse = unknown> {
readonly status: AutoSaveStatus
readonly lastError: string | null
schedule: () => void
flush: () => Promise<void>
destroy: () => void
prime: (payload: TPayload) => void
}
/**
* Creates a reactive autosave store using Svelte 5 runes.
* Must be called within component context (.svelte or .svelte.ts files).
*
* @example
* const autoSave = createAutoSaveStore({
* getPayload: () => formData,
* save: async (payload) => api.put('/endpoint', payload),
* onSaved: (response, { prime }) => {
* formData = response
* prime(response)
* }
* })
*
* // In template: {autoSave.status}
* // Trigger save: autoSave.schedule()
*/
export function createAutoSaveStore<TPayload, TResponse = unknown>(
opts: AutoSaveStoreOptions<TPayload, TResponse>
): AutoSaveStore<TPayload, unknown> {
const debounceMs = opts.debounceMs ?? 2000
const idleResetMs = opts.idleResetMs ?? 2000
let timer: ReturnType<typeof setTimeout> | null = null
let idleResetTimer: ReturnType<typeof setTimeout> | null = null
let controller: AbortController | null = null
let lastSentHash: string | null = null
let status = $state<AutoSaveStatus>('idle')
let lastError = $state<string | null>(null)
function setStatus(next: AutoSaveStatus) {
if (idleResetTimer) {
clearTimeout(idleResetTimer)
idleResetTimer = null
}
status = next
// Auto-transition from 'saved' to 'idle' after idleResetMs
if (next === 'saved') {
idleResetTimer = setTimeout(() => {
status = 'idle'
idleResetTimer = null
}, idleResetMs)
}
}
function prime(payload: TPayload) {
lastSentHash = safeHash(payload)
}
function schedule() {
if (timer) clearTimeout(timer)
timer = setTimeout(() => void run(), debounceMs)
}
async function run() {
if (timer) {
clearTimeout(timer)
timer = null
}
const payload = opts.getPayload()
if (!payload) return
const hash = safeHash(payload)
if (lastSentHash && hash === lastSentHash) return
if (controller) controller.abort()
controller = new AbortController()
setStatus('saving')
lastError = null
try {
const res = await opts.save(payload, { signal: controller.signal })
lastSentHash = hash
setStatus('saved')
if (opts.onSaved) opts.onSaved(res, { prime })
} catch (e: unknown) {
if (e?.name === 'AbortError') {
// Newer save superseded this one
return
}
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
setStatus('offline')
} else {
setStatus('error')
}
lastError = e?.message || 'Auto-save failed'
}
}
function flush() {
return run()
}
function destroy() {
if (timer) clearTimeout(timer)
if (idleResetTimer) clearTimeout(idleResetTimer)
if (controller) controller.abort()
}
return {
get status() {
return status
},
get lastError() {
return lastError
},
schedule,
flush,
destroy,
prime
}
}
function safeHash(obj: unknown): string {
try {
return JSON.stringify(obj)
} catch {
// Fallback for circular structures; not expected for form payloads
return String(obj)
}
}