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
1216 lines
39 KiB
TypeScript
1216 lines
39 KiB
TypeScript
/**
|
||
* @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 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<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)
|
||
})
|
||
})
|
||
})
|