Files
simstudioai--sim/apps/sim/hooks/use-autosave.test.tsx
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1216 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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<string, unknown>() }))
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<void>
delay?: number
enabled?: boolean
draftKey?: string
onRestoreDraft?: (content: string) => void
onDiscardCorrectionFailed?: () => void
}
interface HookHandle {
status: () => SaveStatus
isDirty: () => boolean
saveImmediately: () => Promise<void>
discard: () => void
rerender: (next: Partial<ProbeProps>) => 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(<Probe {...props} />)
})
}
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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 edits 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<void>((resolve) => resolvers.push(resolve))
if (callCount === 2) return new Promise<void>((_, 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<void>(() => {})
})
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<void>((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<void>((resolve) => resolvers.push(resolve))
if (callCount === 2) return new Promise<void>((_, 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<void>((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<void>((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)
})
})
})