/** * @vitest-environment jsdom */ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // jsdom has no real IndexedDB; fake idb-keyval with an in-memory map so draft persistence is // deterministic and inspectable without depending on a browser implementation. const { fakeDraftStore } = vi.hoisted(() => ({ fakeDraftStore: new Map() })) vi.mock('idb-keyval', () => ({ get: vi.fn((key: string) => Promise.resolve(fakeDraftStore.get(key))), set: vi.fn((key: string, value: unknown) => { fakeDraftStore.set(key, value) return Promise.resolve() }), del: vi.fn((key: string) => { fakeDraftStore.delete(key) return Promise.resolve() }), })) import { type SaveStatus, useAutosave } from '@/hooks/use-autosave' interface ProbeProps { content: string savedContent: string onSave: (overrideContent?: string) => Promise delay?: number enabled?: boolean draftKey?: string onRestoreDraft?: (content: string) => void onDiscardCorrectionFailed?: () => void } interface HookHandle { status: () => SaveStatus isDirty: () => boolean saveImmediately: () => Promise discard: () => void rerender: (next: Partial) => void unmount: () => void } /** * Minimal dependency-free hook harness (the repo has no `@testing-library/react`). Mounts the hook * in a real React 19 root under jsdom so effects, refs, and timers run exactly as in the app. */ function renderAutosave(initial: ProbeProps): { handle: HookHandle; props: ProbeProps } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') const root: Root = createRoot(container) const props = { ...initial } let latest = { saveStatus: 'idle' as SaveStatus, isDirty: false, saveImmediately: async () => {}, discard: () => {}, } function Probe(p: ProbeProps) { latest = useAutosave(p) return null } const render = () => { act(() => { root.render() }) } render() const handle: HookHandle = { status: () => latest.saveStatus, isDirty: () => latest.isDirty, saveImmediately: () => latest.saveImmediately(), discard: () => latest.discard(), rerender: (next) => { Object.assign(props, next) render() }, unmount: () => act(() => root.unmount()), } return { handle, props } } /** Flush pending microtasks (awaited promises) inside an act() boundary. */ async function flush() { await act(async () => { await Promise.resolve() await Promise.resolve() }) } describe('useAutosave', () => { beforeEach(() => { vi.useFakeTimers() fakeDraftStore.clear() }) afterEach(() => { vi.runOnlyPendingTimers() vi.useRealTimers() vi.restoreAllMocks() }) it('debounces edits into a single save after the delay', async () => { const onSave = vi.fn(async () => {}) const { handle, props } = renderAutosave({ content: 'a', savedContent: 'a', onSave, delay: 1500, }) expect(handle.isDirty()).toBe(false) // Three rapid edits within the debounce window. handle.rerender({ content: 'ab' }) act(() => void vi.advanceTimersByTime(500)) handle.rerender({ content: 'abc' }) act(() => void vi.advanceTimersByTime(500)) handle.rerender({ content: 'abcd' }) expect(handle.isDirty()).toBe(true) expect(onSave).not.toHaveBeenCalled() // Only after a full quiet delay does exactly one save fire. await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) expect(props.content).toBe('abcd') }) it('holds the "saving" status for the minimum display window, then saved → idle', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(handle.status()).toBe('saving') // Resolve the network save quickly — status must still read "saving" until the min window. await act(async () => { resolveSave?.() }) await flush() expect(handle.status()).toBe('saving') // Simulate the consumer advancing the saved baseline, then cross the min-display floor. handle.rerender({ savedContent: 'a1' }) await act(async () => { vi.advanceTimersByTime(600) }) await flush() expect(handle.status()).toBe('saved') await act(async () => { vi.advanceTimersByTime(2000) }) expect(handle.status()).toBe('idle') }) it('persists the latest content when an edit lands during an in-flight save (no data loss)', async () => { const saved: string[] = [] let resolveSave: (() => void) | undefined const { handle, props } = renderAutosave({ content: 'a', savedContent: 'a', onSave: () => new Promise((resolve) => { saved.push(props.content) resolveSave = resolve }), }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(saved).toEqual(['a1']) // While the PUT is in flight, the user types more. handle.rerender({ content: 'a12' }) // The in-flight save resolves; consumer advances baseline to what was written. handle.rerender({ savedContent: 'a1' }) await act(async () => { resolveSave?.() }) await flush() // The trailing-resave chain must pick up the newer content after the display window. await act(async () => { vi.advanceTimersByTime(600) }) await flush() expect(saved).toEqual(['a1', 'a12']) }) it('stops after an error and does not auto-retry', async () => { const onSave = vi.fn(async () => { throw new Error('boom') }) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() await act(async () => { vi.advanceTimersByTime(600) }) await flush() expect(handle.status()).toBe('error') expect(onSave).toHaveBeenCalledTimes(1) // Content is still dirty but the chain must NOT re-fire on its own. await act(async () => { vi.advanceTimersByTime(5000) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) }) it('saveImmediately cancels the debounce and saves now, but no-ops when clean', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave }) // No-op when nothing is dirty. await act(async () => { await handle.saveImmediately() }) expect(onSave).not.toHaveBeenCalled() // Dirty + Cmd+S saves immediately, before the debounce delay elapses. handle.rerender({ content: 'a1' }) await act(async () => { await handle.saveImmediately() }) await flush() expect(onSave).toHaveBeenCalledTimes(1) }) it('does not save while disabled (the streaming lock)', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, enabled: false, }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).not.toHaveBeenCalled() await act(async () => { await handle.saveImmediately() }) expect(onSave).not.toHaveBeenCalled() }) it('flushes the latest content on unmount when still dirty', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave }) handle.rerender({ content: 'a1' }) // Unmount before the debounce fires — the cleanup flush must still persist the edit. handle.unmount() await flush() expect(onSave).toHaveBeenCalledTimes(1) }) it('writes a local backup on unmount even if the network flush fails', async () => { const onSave = vi.fn(async () => { throw new Error('offline') }) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-unmount', }) handle.rerender({ content: 'a1' }) // Unmount before the 400ms local-draft debounce fires — the pending timer is cancelled, so // the cleanup itself must persist the draft, not just attempt (and here, fail) the network flush. handle.unmount() await flush() expect(fakeDraftStore.get('autosave-draft:file-unmount')).toEqual({ content: 'a1', savedContent: 'a', }) }) it('does not flush on unmount when the document is clean', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave }) handle.unmount() await flush() expect(onSave).not.toHaveBeenCalled() }) describe('local draft persistence (draftKey)', () => { it('mirrors dirty edits into IndexedDB on a short debounce, independent of the network save', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-1', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(400) }) await flush() // The local draft lands well before the (longer) network debounce fires. expect(fakeDraftStore.get('autosave-draft:file-1')).toEqual({ content: 'a1', savedContent: 'a', }) expect(onSave).not.toHaveBeenCalled() resolveSave?.() }) it('clears the local draft once the network save succeeds and the caller advances savedContent', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-2', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() // The network save is in flight; the local draft is still the only record of the edit. expect(fakeDraftStore.has('autosave-draft:file-2')).toBe(true) handle.rerender({ savedContent: 'a1' }) await act(async () => { resolveSave?.() }) await flush() expect(fakeDraftStore.has('autosave-draft:file-2')).toBe(false) }) it('does not clear the local draft when a newer edit lands while the save is in flight', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-2b', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(fakeDraftStore.has('autosave-draft:file-2b')).toBe(true) // A further edit lands while the first save is still in flight. handle.rerender({ content: 'a12' }) await act(async () => { vi.advanceTimersByTime(400) }) await flush() // The first save resolves; the caller advances savedContent only to the snapshot it saved. handle.rerender({ savedContent: 'a1' }) await act(async () => { resolveSave?.() }) await flush() // Still dirty ('a12' !== 'a1') — the local backup for the untransmitted edit must survive, // even though `save()`'s success no longer explicitly clears it. expect(fakeDraftStore.has('autosave-draft:file-2b')).toBe(true) expect(fakeDraftStore.get('autosave-draft:file-2b')).toMatchObject({ content: 'a12' }) }) it('flushes the draft to IndexedDB when the page becomes hidden', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-3', }) handle.rerender({ content: 'a1' }) // No timers advanced — simulates a tab close mid-keystroke, before either debounce fires. Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }) document.dispatchEvent(new Event('visibilitychange')) await flush() expect(fakeDraftStore.get('autosave-draft:file-3')).toEqual({ content: 'a1', savedContent: 'a', }) }) it('restores a draft left behind by a prior session, once, on mount', async () => { fakeDraftStore.set('autosave-draft:file-4', { content: 'recovered', savedContent: 'a' }) const onSave = vi.fn(async () => {}) const onRestoreDraft = vi.fn() renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-4', onRestoreDraft, }) await flush() expect(onRestoreDraft).toHaveBeenCalledTimes(1) expect(onRestoreDraft).toHaveBeenCalledWith('recovered') }) it('does not clobber a fresh edit made while the recovery read was still in flight', async () => { fakeDraftStore.set('autosave-draft:file-6', { content: 'recovered', savedContent: 'a' }) const onSave = vi.fn(async () => {}) const onRestoreDraft = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-6', onRestoreDraft, }) // The user types before the async IndexedDB read resolves. handle.rerender({ content: 'user-typed' }) await flush() expect(onRestoreDraft).not.toHaveBeenCalled() }) it('discards and purges a stale draft when the server baseline has moved on', async () => { fakeDraftStore.set('autosave-draft:file-5', { content: 'stale-edit', savedContent: 'old-baseline', }) const onSave = vi.fn(async () => {}) const onRestoreDraft = vi.fn() renderAutosave({ content: 'new-baseline', savedContent: 'new-baseline', onSave, draftKey: 'file-5', onRestoreDraft, }) await flush() expect(onRestoreDraft).not.toHaveBeenCalled() // Purged, not merely skipped — otherwise a later open where the baseline coincidentally // matches this stale snapshot again would silently resurrect the outdated edit. expect(fakeDraftStore.has('autosave-draft:file-5')).toBe(false) }) it('attempts recovery only once per mount, not every time draftKey toggles across a streaming lock', async () => { fakeDraftStore.set('autosave-draft:file-once', { content: 'recovered', savedContent: 'a' }) const onSave = vi.fn(async () => {}) const onRestoreDraft = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-once', enabled: true, onRestoreDraft, }) await flush() expect(onRestoreDraft).toHaveBeenCalledTimes(1) // Mirrors autosave being disabled during agent streaming (effectiveDraftKey -> undefined) // and re-enabled once the stream settles (effectiveDraftKey -> defined again). A stale // pre-stream draft left behind in the meantime must not be re-offered on the second pass. fakeDraftStore.set('autosave-draft:file-once', { content: 'pre-agent-edit', savedContent: 'a', }) handle.rerender({ enabled: false }) await flush() handle.rerender({ enabled: true }) await flush() expect(onRestoreDraft).toHaveBeenCalledTimes(1) }) it('discard clears the local draft immediately and blocks any further write, even mid-race with unmount', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(400) }) await flush() expect(fakeDraftStore.has('autosave-draft:file-discard')).toBe(true) // Discard fires before the caller's content===savedContent reset has landed — the hook's // own flag must block persistence regardless of that race, not just the IndexedDB delete. act(() => handle.discard()) await flush() expect(fakeDraftStore.has('autosave-draft:file-discard')).toBe(false) // Simulate the unmount flush racing in right after discard, while still (from the hook's // perspective) dirty: neither the local draft nor the network save must fire. handle.unmount() await flush() expect(fakeDraftStore.has('autosave-draft:file-discard')).toBe(false) expect(onSave).not.toHaveBeenCalled() }) it('discard prevents a pending debounced network save from firing', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-2', }) handle.rerender({ content: 'a1' }) act(() => handle.discard()) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).not.toHaveBeenCalled() }) it('corrects the server with the captured baseline, even if the caller never resets content in time', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-3', }) // A save is genuinely in flight (discardedRef can't stop it — it already started). handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) // The user discards while that save is still pending. Deliberately do NOT rerender content // back to the baseline here — the correction must not depend on that caller-side reset // landing before this continuation runs; it must push the baseline it captured at discard(). act(() => handle.discard()) await act(async () => { resolveSave?.() }) await flush() // The stale in-flight write landed; a corrective save fires with the captured baseline ('a'), // not whatever the still-dirty ambient content ('a1') happened to be. expect(onSave).toHaveBeenCalledTimes(2) expect(onSave).toHaveBeenNthCalledWith(2, 'a') }) it('surfaces a corrective save failure instead of only logging it', async () => { let resolveSave: (() => void) | undefined const onSave = vi.fn( () => new Promise((resolve) => { resolveSave = resolve }) ) const onDiscardCorrectionFailed = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-6', onDiscardCorrectionFailed, }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() act(() => handle.discard()) // The corrective save (the second call) fails. onSave.mockImplementationOnce(() => Promise.reject(new Error('offline'))) await act(async () => { resolveSave?.() }) await flush() expect(onDiscardCorrectionFailed).toHaveBeenCalledTimes(1) }) it('resumes normal autosave for a genuinely new edit made after discard', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-7', }) handle.rerender({ content: 'a1' }) act(() => handle.discard()) // The caller resets content back to the baseline, mirroring discardChanges. handle.rerender({ content: 'a' }) // The editor stays mounted a moment longer and the user starts a fresh edit. handle.rerender({ content: 'b' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() // A brand-new edit made after discard must not be silently swallowed forever. expect(onSave).toHaveBeenCalledTimes(1) expect(onSave).toHaveBeenCalledWith() }) it('does not issue a corrective save when discard finds nothing in flight', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-4', }) handle.rerender({ content: 'a1' }) act(() => handle.discard()) await flush() expect(onSave).not.toHaveBeenCalled() }) it('does not treat a long-settled save as in flight when discard runs much later', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-5', }) // A save fully completes well before discard is ever called. handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() handle.rerender({ savedContent: 'a1' }) await act(async () => { vi.advanceTimersByTime(600) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) // A later, unrelated edit is discarded. inFlightRef must have been cleared when the first // save settled — otherwise this reads it as still "in flight" and fires a stale corrective // save with whatever savedContent happened to be at that (now long-past) moment. handle.rerender({ content: 'a12' }) act(() => handle.discard()) await flush() expect(onSave).toHaveBeenCalledTimes(1) }) it('does not let a stale discard correction clobber a genuinely new edit made afterward', async () => { const resolvers: Array<() => void> = [] const onSave = vi.fn( () => new Promise((resolve) => { resolvers.push(resolve) }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-8', }) // A save is in flight when the user discards. handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) act(() => handle.discard()) // The caller resets content to the baseline, then the user types something genuinely new // before the in-flight save (and its scheduled correction) has settled. handle.rerender({ content: 'a' }) handle.rerender({ content: 'a2' }) // The in-flight save resolves; the correction runs but must defer, since content has // moved on to something that's neither the discarded baseline nor what it was at discard. await act(async () => { resolvers[0]?.() }) await flush() await act(async () => { vi.advanceTimersByTime(600) }) await flush() // A fresh save for the new edit fires instead of a correction pushing the stale baseline. const targets = onSave.mock.calls.map((call) => call[0]) expect(targets).not.toContain('a') expect(onSave.mock.calls.length).toBeGreaterThan(1) resolvers[resolvers.length - 1]?.() await flush() }) it('does not let the original save leftover status timer clobber a still-running correction', async () => { const resolvers: Array<() => void> = [] const onSave = vi.fn( () => new Promise((resolve) => { resolvers.push(resolve) }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-9', }) // A save is in flight when the user discards. handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) act(() => handle.discard()) handle.rerender({ content: 'a' }) // The original save resolves; the correction starts (savingRef/inFlightRef now belong to it). await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) // The original save's own MIN_SAVING_DISPLAY_MS timer fires while the correction is still // unresolved. Without the inFlightRef guard, this used to unconditionally reset savingRef, // letting a debounced save for a new edit start concurrently with the correction. await act(async () => { vi.advanceTimersByTime(600) }) await flush() // A new edit made in this window must not be able to schedule a concurrent save while the // correction (still unresolved) rightfully holds the mutex. handle.rerender({ content: 'a2' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(2) resolvers[1]?.() await flush() }) it('reaches a terminal status once the discard correction settles instead of sticking on saving', async () => { const resolvers: Array<() => void> = [] const onSave = vi.fn( () => new Promise((resolve) => { resolvers.push(resolve) }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-10', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) act(() => handle.discard()) handle.rerender({ content: 'a' }) // The original save resolves; the correction starts. Its own MIN_SAVING_DISPLAY_MS timer // must not resolve status to 'saved'/'idle' for content the user no longer sees. await act(async () => { resolvers[0]?.() }) await flush() await act(async () => { vi.advanceTimersByTime(600) }) await flush() expect(onSave).toHaveBeenCalledTimes(2) expect(handle.status()).toBe('saving') // The correction itself settles — without this, status stayed on 'saving' forever. await act(async () => { resolvers[1]?.() }) await flush() expect(handle.status()).toBe('idle') }) it('surfaces an error if the discard correction itself fails, rather than drifting to idle', async () => { const resolvers: Array<() => void> = [] const rejecters: Array<(error: Error) => void> = [] let callCount = 0 const onSave = vi.fn( () => new Promise((resolve, reject) => { callCount += 1 if (callCount === 1) resolvers.push(resolve) else rejecters.push(reject) }) ) const onDiscardCorrectionFailed = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-11', onDiscardCorrectionFailed, }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() act(() => handle.discard()) handle.rerender({ content: 'a' }) await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) await act(async () => { rejecters[0]?.(new Error('network down')) }) await flush() expect(onDiscardCorrectionFailed).toHaveBeenCalledTimes(1) expect(handle.status()).toBe('error') }) it('does not let discard suppression from a previous file block saves for a newly switched-to file', async () => { const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-x', }) handle.rerender({ content: 'a1' }) act(() => handle.discard()) handle.rerender({ content: 'a' }) // Switch to a different file whose content coincidentally equals the previous file's // discard target — without keying discard suppression to draftKey, this would stay // wrongly suppressed forever, since content would never differ from the stale target. handle.rerender({ draftKey: 'file-y', savedContent: 'z', content: 'a' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) }) it('autosaves an edit made while a discard correction was in flight, once the correction settles', async () => { const resolvers: Array<() => void> = [] const onSave = vi.fn( () => new Promise((resolve) => { resolvers.push(resolve) }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-12', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) act(() => handle.discard()) handle.rerender({ content: 'a' }) // The original save resolves; the correction starts and holds the savingRef mutex. await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) // The user keeps typing while the correction is still in flight. The debounce effect sees // savingRef held and bails — without a re-chain once the mutex frees, this edit would never // autosave until some unrelated future change happened to fire the effect again. handle.rerender({ content: 'a2' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(2) await act(async () => { resolvers[1]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(3) expect(onSave).toHaveBeenLastCalledWith() }) it('surfaces the newer edit’s own save outcome if a discard correction fails after being superseded', async () => { const resolvers: Array<() => void> = [] const rejecters: Array<(error: Error) => void> = [] let callCount = 0 const onSave = vi.fn(() => { callCount += 1 if (callCount === 1) return new Promise((resolve) => resolvers.push(resolve)) if (callCount === 2) return new Promise((_, reject) => rejecters.push(reject)) // The third call is the superseding edit's own save — never resolved, since we only // need to observe that it was picked up at all. return new Promise(() => {}) }) const onDiscardCorrectionFailed = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-13', onDiscardCorrectionFailed, }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() act(() => handle.discard()) handle.rerender({ content: 'a' }) await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) // A newer edit supersedes the in-flight correction before it settles. handle.rerender({ content: 'a2' }) await act(async () => { rejecters[0]?.(new Error('network down')) }) await flush() // The failed correction still reports itself, but the superseding edit's own save must be // picked up immediately instead of leaving the hook stalled on a dead mutex. expect(onDiscardCorrectionFailed).toHaveBeenCalledTimes(1) expect(onSave).toHaveBeenCalledTimes(3) expect(onSave).toHaveBeenLastCalledWith() }) it('does not lift discard suppression when only `enabled` toggles for the same document', async () => { const resolvers: Array<() => void> = [] const rejecters: Array<(error: Error) => void> = [] const onSave = vi.fn( () => new Promise((resolve, reject) => { resolvers.push(resolve) rejecters.push(reject) }) ) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-14', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() expect(onSave).toHaveBeenCalledTimes(1) act(() => handle.discard()) handle.rerender({ content: 'a' }) // The correction starts once the original save settles. await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) // A streaming lock (or any other reason `enabled` flips) toggles for the SAME document // while the correction is still in flight — this must not be mistaken for switching files // and clear discard's ownership of saveStatus. handle.rerender({ enabled: false }) handle.rerender({ enabled: true }) // Without keying off the raw draftKey, the `enabled` round-trip would have cleared // discardedRef, so the correction's failure handler would skip setSaveStatus('error') and // leave the hook stuck on 'saving' with no visible retry affordance. await act(async () => { rejecters[1]?.(new Error('network down')) }) await flush() expect(handle.status()).toBe('error') }) it('retries a failed discard correction via saveImmediately instead of silently no-opping', async () => { const resolvers: Array<() => void> = [] const rejecters: Array<(error: Error) => void> = [] let callCount = 0 const onSave = vi.fn(() => { callCount += 1 if (callCount === 1) return new Promise((resolve) => resolvers.push(resolve)) if (callCount === 2) return new Promise((_, reject) => rejecters.push(reject)) return Promise.resolve() }) const onDiscardCorrectionFailed = vi.fn() const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-discard-15', onDiscardCorrectionFailed, }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(1500) }) await flush() act(() => handle.discard()) handle.rerender({ content: 'a' }) await act(async () => { resolvers[0]?.() }) await flush() expect(onSave).toHaveBeenCalledTimes(2) await act(async () => { rejecters[0]?.(new Error('network down')) }) await flush() expect(onDiscardCorrectionFailed).toHaveBeenCalledTimes(1) expect(handle.status()).toBe('error') // Content already equals savedContent (that's what discard reverted to), so a naive retry // through save()'s dirty-check would be a silent no-op. saveImmediately must still push // the reverted baseline again. await act(async () => { await handle.saveImmediately() }) await flush() expect(onSave).toHaveBeenCalledTimes(3) expect(onSave).toHaveBeenLastCalledWith('a') expect(handle.status()).toBe('idle') }) it('serializes IndexedDB writes and deletes so a slow write cannot resurrect a discarded draft', async () => { let resolveWrite: (() => void) | undefined const idbKeyval = await import('idb-keyval') vi.mocked(idbKeyval.set).mockImplementationOnce( (key: string, value: unknown) => new Promise((resolve) => { resolveWrite = () => { fakeDraftStore.set(key, value) resolve() } }) ) const onSave = vi.fn(async () => {}) const { handle } = renderAutosave({ content: 'a', savedContent: 'a', onSave, draftKey: 'file-race', }) handle.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(400) }) await flush() // The write is now in flight (not yet resolved) when discard's delete is queued behind it. act(() => handle.discard()) await flush() expect(fakeDraftStore.has('autosave-draft:file-race')).toBe(false) // The slow write finally resolves — it must not resurrect the entry the queued delete removed. resolveWrite?.() await flush() expect(fakeDraftStore.has('autosave-draft:file-race')).toBe(false) }) it('serializes IndexedDB ops across an unmount and a fresh remount of the same draft key', async () => { let resolveWrite: (() => void) | undefined const idbKeyval = await import('idb-keyval') vi.mocked(idbKeyval.set).mockImplementationOnce( (key: string, value: unknown) => new Promise((resolve) => { resolveWrite = () => { fakeDraftStore.set(key, value) resolve() } }) ) const onSaveA = vi.fn(async () => {}) const { handle: handleA } = renderAutosave({ content: 'a', savedContent: 'a', onSave: onSaveA, draftKey: 'file-cross-mount', }) handleA.rerender({ content: 'a1' }) await act(async () => { vi.advanceTimersByTime(400) }) await flush() // The write is in flight when this instance unmounts — its own idbQueueRef dies with it, // but the operation must still be ordered relative to whatever comes next for this key. handleA.unmount() await flush() // A fresh instance mounts for the same file and immediately discards. const onSaveB = vi.fn(async () => {}) const { handle: handleB } = renderAutosave({ content: 'a', savedContent: 'a', onSave: onSaveB, draftKey: 'file-cross-mount', }) act(() => handleB.discard()) await flush() // The old instance's slow write finally resolves — it must not resurrect the entry the // new instance's delete already removed, even though they belong to different mounts. resolveWrite?.() await flush() expect(fakeDraftStore.has('autosave-draft:file-cross-mount')).toBe(false) }) }) })