From b0f9bf916109e5a400c29321b9936a21ae210bd6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 18:17:05 -0700 Subject: [PATCH 1/4] fix(files): fix savingRef mutex integrity race, anchor discard un-suppress to captured target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent 4-agent audit (beyond the bot review loop) converged on a real race in the round-11 discard-correction fix: - save()'s deferred MIN_SAVING_DISPLAY_MS status timer resolves as a macrotask well after the save's own promise (used to sequence discard's correction) has already settled as a microtask. That timer unconditionally reset savingRef/triggered a trailing resave, even after discard's correction had since claimed the save slot for its own in-flight write — letting a fresh debounced save start concurrently with the correction. Now guarded with `if (inFlightRef.current) return` before touching savingRef, so it only acts when nothing else has claimed the slot since. - The render-time un-suppress check keyed off isDirty, which a stale save's markSavedContent(next) landing after discard can transiently corrupt (overwriting savedContent with the pre-discard value while content has already been reverted), causing a spurious "genuinely new edit" signal. Now keyed off a discardTargetRef captured at discard time instead, which that corruption doesn't touch. Also hardened recovery to key its once-per-mount guard on the specific draft key rather than a bare boolean, so a hypothetical future caller that reuses a hook instance across files would still get correct recovery (today's real callers already remount per file, so this is defense in depth, not a behavior change for any current caller). --- apps/sim/hooks/use-autosave.test.tsx | 53 ++++++++++++++++++++++++++++ apps/sim/hooks/use-autosave.ts | 15 ++++---- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/sim/hooks/use-autosave.test.tsx b/apps/sim/hooks/use-autosave.test.tsx index ff92667b219..4981aed1a01 100644 --- a/apps/sim/hooks/use-autosave.test.tsx +++ b/apps/sim/hooks/use-autosave.test.tsx @@ -763,6 +763,59 @@ describe('useAutosave', () => { 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('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') diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 05e1f10d6d0..8f04d6c4e6c 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -106,9 +106,10 @@ export function useAutosave({ const lastPersistedContentRef = useRef(null) const discardedRef = useRef(false) + const discardTargetRef = useRef(null) const isDirty = content !== savedContent - if (discardedRef.current && isDirty) discardedRef.current = false + if (discardedRef.current && content !== discardTargetRef.current) discardedRef.current = false const persistLocalDraft = useCallback(() => { const key = draftKeyRef.current @@ -165,9 +166,10 @@ export function useAutosave({ const elapsed = Date.now() - savingStartRef.current const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) displayTimerRef.current = setTimeout(() => { - setSaveStatus(nextStatus) + if (!discardedRef.current) setSaveStatus(nextStatus) clearTimeout(idleTimerRef.current) idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + if (inFlightRef.current) return savingRef.current = false if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) { save() @@ -245,11 +247,11 @@ export function useAutosave({ } }, [effectiveDraftKey, persistLocalDraft]) - const recoveryAttemptedRef = useRef(false) + const recoveredForKeyRef = useRef(null) useEffect(() => { - if (!effectiveDraftKey || recoveryAttemptedRef.current) return - recoveryAttemptedRef.current = true + if (!effectiveDraftKey || recoveredForKeyRef.current === effectiveDraftKey) return + recoveredForKeyRef.current = effectiveDraftKey let cancelled = false void enqueueDraftOp(effectiveDraftKey, () => get(localDraftDbKey(effectiveDraftKey)) @@ -279,12 +281,13 @@ export function useAutosave({ const discard = useCallback(() => { discardedRef.current = true + discardTargetRef.current = savedContentRef.current clearTimeout(timerRef.current) clearTimeout(localDraftTimerRef.current) clearLocalDraft() const pendingSave = inFlightRef.current if (!pendingSave) return - const target = savedContentRef.current + const target = discardTargetRef.current const contentAtDiscard = contentRef.current void pendingSave.then(() => { const current = contentRef.current From d278a7e53416f9f7ff3601b5105d2816c2067856 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 18:31:31 -0700 Subject: [PATCH 2/4] fix(files): fix discard status masking and cross-key discard suppression leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile round-1 review on the follow-up fix PR caught 3 real gaps in the discard/correction flow: - The display timer's !discardedRef guard (added to stop a stale save's status update from clobbering a running correction) also silently suppressed the idle-timer reschedule, and discard()'s own correction never set a terminal status on settle — so saveStatus could stick on 'saving' forever after a successful correction, and a failed correction surfaced only via the onDiscardCorrectionFailed callback with no status change. Now the correction's own .then()/.catch() sets 'idle'/'error' once it owns the flow (only when nothing has since un-suppressed discard). - The discard-suppression un-suppress check compared content against the captured discard target, but never reset if a hook instance were reused across draftKeys — a coincidental content match with the previous file's target would keep discard permanently suppressing saves for the new file. Reset discardedRef whenever the effective draftKey changes. --- apps/sim/hooks/use-autosave.test.tsx | 112 +++++++++++++++++++++++++++ apps/sim/hooks/use-autosave.ts | 31 ++++++-- 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/apps/sim/hooks/use-autosave.test.tsx b/apps/sim/hooks/use-autosave.test.tsx index 4981aed1a01..19fe6792c40 100644 --- a/apps/sim/hooks/use-autosave.test.tsx +++ b/apps/sim/hooks/use-autosave.test.tsx @@ -816,6 +816,118 @@ describe('useAutosave', () => { 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('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') diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 8f04d6c4e6c..611c8196be4 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -96,6 +96,7 @@ export function useAutosave({ const effectiveDraftKey = enabled ? draftKey : undefined const draftKeyRef = useRef(effectiveDraftKey) + const draftKeyChanged = draftKeyRef.current !== effectiveDraftKey draftKeyRef.current = effectiveDraftKey const onRestoreDraftRef = useRef(onRestoreDraft) onRestoreDraftRef.current = onRestoreDraft @@ -107,6 +108,9 @@ export function useAutosave({ const discardedRef = useRef(false) const discardTargetRef = useRef(null) + // A hook instance reused across draftKeys (today's callers all remount per file instead) must + // not carry a discard suppression from the previous file into the new one. + if (draftKeyChanged) discardedRef.current = false const isDirty = content !== savedContent if (discardedRef.current && content !== discardTargetRef.current) discardedRef.current = false @@ -166,9 +170,14 @@ export function useAutosave({ const elapsed = Date.now() - savingStartRef.current const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed) displayTimerRef.current = setTimeout(() => { - if (!discardedRef.current) setSaveStatus(nextStatus) - clearTimeout(idleTimerRef.current) - idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + // While discarded, status is owned by discard()'s corrective save instead — this + // save's outcome no longer reflects what the user is looking at, and letting the + // idle-timer fire anyway would prematurely clear a status the correction just set. + if (!discardedRef.current) { + setSaveStatus(nextStatus) + clearTimeout(idleTimerRef.current) + idleTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + } if (inFlightRef.current) return savingRef.current = false if (nextStatus !== 'error' && contentRef.current !== savedContentRef.current) { @@ -295,10 +304,18 @@ export function useAutosave({ savingRef.current = true const correctionRun = onSaveRef .current(target) - .catch((error) => { - logger.warn('Corrective save after discard failed', { error }) - onDiscardCorrectionFailedRef.current?.() - }) + .then( + () => { + // Only ours to set if nothing has since un-suppressed discard (a newer edit) — that + // flow owns status once it takes over. + if (!unmountedRef.current && discardedRef.current) setSaveStatus('idle') + }, + (error) => { + logger.warn('Corrective save after discard failed', { error }) + onDiscardCorrectionFailedRef.current?.() + if (!unmountedRef.current && discardedRef.current) setSaveStatus('error') + } + ) .finally(() => { savingRef.current = false inFlightRef.current = null From 4c03c1db36ca74b8aa999f61b9ec589773167ad7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 18:44:56 -0700 Subject: [PATCH 3/4] fix(files): re-chain autosave after a discard correction settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot found the discard correction's finally() cleared the save mutex but never rechecked for dirty content: an edit made while the correction was in flight bailed out of the debounce effect (savingRef was held) and, since content isn't a savingRef dependency, was never rescheduled once the mutex freed — the edit could sit unsaved indefinitely. The same gap explained a related report that a failed correction which had already been superseded by a newer edit left saveStatus stuck, since nothing else was driving it forward. Fix is one line: call save() in the finally() when content is still dirty. save()'s own guards (savingRef/discardedRef/content-equality) make this safe to call unconditionally, and it naturally hands status ownership to the newer edit's own save cycle. --- apps/sim/hooks/use-autosave.test.tsx | 99 ++++++++++++++++++++++++++++ apps/sim/hooks/use-autosave.ts | 7 +- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/apps/sim/hooks/use-autosave.test.tsx b/apps/sim/hooks/use-autosave.test.tsx index 19fe6792c40..c4cf081874f 100644 --- a/apps/sim/hooks/use-autosave.test.tsx +++ b/apps/sim/hooks/use-autosave.test.tsx @@ -928,6 +928,105 @@ describe('useAutosave', () => { 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('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') diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index 611c8196be4..a2981b4b0b4 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -319,11 +319,16 @@ export function useAutosave({ .finally(() => { savingRef.current = false inFlightRef.current = null + // A newer edit made while the correction was in flight bailed out of the debounce + // effect (savingRef was held) and never got rescheduled — pick it up now that the + // mutex is free. This also gives that edit's own save cycle ownership of saveStatus, + // covering a correction that failed after a newer edit already un-suppressed discard. + if (!unmountedRef.current && contentRef.current !== savedContentRef.current) save() }) inFlightRef.current = correctionRun return correctionRun }) - }, [clearLocalDraft]) + }, [clearLocalDraft, save]) return { saveStatus, saveImmediately, isDirty, discard } } From 76964d4f74f47df4d32b260f2ad3abfbf820b2d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 9 Jul 2026 18:54:57 -0700 Subject: [PATCH 4/4] fix(files): key discard state to raw draftKey, make failed corrections retryable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot round 3 caught 2 more real gaps: - The document-change reset added last round compared effectiveDraftKey (draftKey gated by enabled), so toggling enabled alone for the SAME document — e.g. a streaming lock — looked identical to switching files. That cleared discardedRef mid-correction, which skipped the correction's own setSaveStatus('error'/'idle') (gated on discardedRef to avoid clobbering a newer edit's status), silently stranding the hook on 'saving' with no visible retry affordance. Now keyed off the raw draftKey, which enabled toggling never touches. - After a failed correction, content already equals savedContent (that's what discard reverted to), so saveImmediately()'s retry going through save() hit its dirty-check and was a complete no-op — the error toast's Retry button did nothing. Extracted the correction logic into runCorrection(target), shared by discard() and by saveImmediately when a failed correction is pending, so retry pushes the reverted baseline again instead of bailing on a check that assumes retries are always for dirty content. --- apps/sim/hooks/use-autosave.test.tsx | 101 +++++++++++++++++++++++++++ apps/sim/hooks/use-autosave.ts | 76 +++++++++++++------- 2 files changed, 153 insertions(+), 24 deletions(-) diff --git a/apps/sim/hooks/use-autosave.test.tsx b/apps/sim/hooks/use-autosave.test.tsx index c4cf081874f..0f035fee248 100644 --- a/apps/sim/hooks/use-autosave.test.tsx +++ b/apps/sim/hooks/use-autosave.test.tsx @@ -1027,6 +1027,107 @@ describe('useAutosave', () => { 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') diff --git a/apps/sim/hooks/use-autosave.ts b/apps/sim/hooks/use-autosave.ts index a2981b4b0b4..aaf2d3d6b88 100644 --- a/apps/sim/hooks/use-autosave.ts +++ b/apps/sim/hooks/use-autosave.ts @@ -96,7 +96,6 @@ export function useAutosave({ const effectiveDraftKey = enabled ? draftKey : undefined const draftKeyRef = useRef(effectiveDraftKey) - const draftKeyChanged = draftKeyRef.current !== effectiveDraftKey draftKeyRef.current = effectiveDraftKey const onRestoreDraftRef = useRef(onRestoreDraft) onRestoreDraftRef.current = onRestoreDraft @@ -108,12 +107,23 @@ export function useAutosave({ const discardedRef = useRef(false) const discardTargetRef = useRef(null) - // A hook instance reused across draftKeys (today's callers all remount per file instead) must - // not carry a discard suppression from the previous file into the new one. - if (draftKeyChanged) discardedRef.current = false + const failedCorrectionTargetRef = useRef(null) + // Keyed off the raw `draftKey`, not `effectiveDraftKey` — the latter also flips with `enabled` + // (e.g. a streaming lock toggling for the SAME document), which must not be mistaken for a + // hook instance being reused across documents (today's callers all remount per file instead). + const documentKeyRef = useRef(draftKey) + const documentChanged = documentKeyRef.current !== draftKey + documentKeyRef.current = draftKey + if (documentChanged) { + discardedRef.current = false + failedCorrectionTargetRef.current = null + } const isDirty = content !== savedContent - if (discardedRef.current && content !== discardTargetRef.current) discardedRef.current = false + if (discardedRef.current && content !== discardTargetRef.current) { + discardedRef.current = false + failedCorrectionTargetRef.current = null + } const persistLocalDraft = useCallback(() => { const key = draftKeyRef.current @@ -283,34 +293,21 @@ export function useAutosave({ } }, [effectiveDraftKey, clearLocalDraft]) - const saveImmediately = useCallback(async () => { - clearTimeout(timerRef.current) - await save() - }, [save]) - - const discard = useCallback(() => { - discardedRef.current = true - discardTargetRef.current = savedContentRef.current - clearTimeout(timerRef.current) - clearTimeout(localDraftTimerRef.current) - clearLocalDraft() - const pendingSave = inFlightRef.current - if (!pendingSave) return - const target = discardTargetRef.current - const contentAtDiscard = contentRef.current - void pendingSave.then(() => { - const current = contentRef.current - if (inFlightRef.current || (current !== target && current !== contentAtDiscard)) return + /** Pushes `target` (the reverted baseline) to the server. Used both by `discard()`'s initial correction and by `saveImmediately`'s retry of a correction that previously failed — content already equals `target` in both cases, so this bypasses `save()`'s dirty-check entirely rather than special-casing it there. */ + const runCorrection = useCallback( + (target: string) => { savingRef.current = true const correctionRun = onSaveRef .current(target) .then( () => { + failedCorrectionTargetRef.current = null // Only ours to set if nothing has since un-suppressed discard (a newer edit) — that // flow owns status once it takes over. if (!unmountedRef.current && discardedRef.current) setSaveStatus('idle') }, (error) => { + failedCorrectionTargetRef.current = target logger.warn('Corrective save after discard failed', { error }) onDiscardCorrectionFailedRef.current?.() if (!unmountedRef.current && discardedRef.current) setSaveStatus('error') @@ -327,8 +324,39 @@ export function useAutosave({ }) inFlightRef.current = correctionRun return correctionRun + }, + [save] + ) + + const saveImmediately = useCallback(async () => { + clearTimeout(timerRef.current) + // Retrying after a failed correction: content already equals savedContent (that's what + // discard reverted to), so save()'s own dirty-check would treat this as a no-op and the + // error's retry button would silently do nothing. + if (failedCorrectionTargetRef.current !== null) { + await runCorrection(failedCorrectionTargetRef.current) + return + } + await save() + }, [save, runCorrection]) + + const discard = useCallback(() => { + discardedRef.current = true + discardTargetRef.current = savedContentRef.current + failedCorrectionTargetRef.current = null + clearTimeout(timerRef.current) + clearTimeout(localDraftTimerRef.current) + clearLocalDraft() + const pendingSave = inFlightRef.current + if (!pendingSave) return + const target = discardTargetRef.current + const contentAtDiscard = contentRef.current + void pendingSave.then(() => { + const current = contentRef.current + if (inFlightRef.current || (current !== target && current !== contentAtDiscard)) return + runCorrection(target) }) - }, [clearLocalDraft, save]) + }, [clearLocalDraft, runCorrection]) return { saveStatus, saveImmediately, isDirty, discard } }