mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(files): remove Save UI in favor of silent autosave with local-first draft recovery (#5549)
* improvement(files): remove Save UI in favor of silent autosave with local-first draft recovery Files editor no longer shows a Save/Saving/Save failed button - autosave already ran in the background, the button was vestigial. Cmd+S still works. Adds local-first draft persistence to the shared useAutosave hook (opt-in via draftKey): edits mirror into IndexedDB on a 400ms debounce, independent of the 1.5s network save, and flush best-effort on visibilitychange/pagehide. On reopen, a newer local draft is silently recovered and resynced. This replaces the beforeunload "leave site?" warning, which only blocked navigation - it never actually saved anything. A toast (with a Retry action) surfaces a save failure, since there's no more persistent status indicator to show it. * improvement(files): simplify autosave draft persistence and dedupe editor resync Structural cleanup after the local-draft feature: draftKey is now ANDed with enabled inside useAutosave itself (rather than trusting every caller to replicate that gating), the combined dirty-transition/debounce effect is split into two single-purpose effects, redundant back-to-back IndexedDB writes on visibilitychange+pagehide are deduped, a dead identity wrapper around setDraftContent is removed, and the three near-identical "resync editor body if changed" blocks in rich-markdown-editor.tsx collapse into one local helper. * fix(files): flush pending local draft on unmount, fix stale Retry target Two real bugs from Greptile's first review pass: - Unmounting before the 400ms local-draft debounce fired cancelled the pending timer without ever writing the draft, so if the network flush also failed on the way out, the edit had no backup anywhere. The unmount cleanup now calls persistLocalDraft() synchronously before attempting the network flush. - The save-failure toast's Retry action read saveRef.current lazily at click time, so navigating to a different file before clicking Retry would retry-save the wrong file. It now captures the failing file's save function at the moment the toast is created. * fix(files): Discard Changes now actually resets editor content Discard previously only cleared the parent's mirrored isDirty/saveStatus state; the editor's own content was never reset to match the server baseline. On unmount, useAutosave's flush logic saw content still diverged and (a) re-saved the "discarded" edit to the server, and (b) after this PR's local-draft addition, also persisted it to IndexedDB — so even a future fix to (a) would still have the draft resurrect the discarded text on next open. Adds a discardRef bridge (mirrors the existing saveRef pattern) so Discard resets the editor's draft content back to savedContent before navigating away, closing both paths at the root. * fix(files): make Discard deterministic, independent of state-update timing The previous discard fix (setDraftContent(savedContent) before navigating) relied on that dispatch landing before the FileViewer unmounts. If unmount raced ahead of it, the autosave cleanup would still see stale dirty content and could resurrect the discarded edit via the local draft. useAutosave now exposes discard(): it flags the instance as discarded, cancels any pending timers, and clears the local draft immediately. Every write path (persistLocalDraft, save, the unmount flush) checks that flag first, so nothing written after discard() can bring the edit back, regardless of whether the content-reset render has committed yet. * fix(files): correct in-flight save after discard, fix IndexedDB write/delete ordering Two more real races from round 4 of review: - discard() couldn't stop a save that had already started (discardedRef only blocks saves not yet begun). Once that in-flight save lands, it now schedules a corrective save to push the reverted content, rather than leaving the discarded edit on the server permanently. Only fires when a save was genuinely in flight at discard time. - persistLocalDraft's set() and clearLocalDraft's del() were independent promises with no ordering guarantee. A slow write starting before discard could resolve after discard's delete and resurrect the draft. Both now go through a single serialized queue per hook instance, so a delete queued after a write always runs after it completes. * fix(files): make discard's corrective save use an explicit baseline The corrective save (from the previous fix) relied on the caller's setDraftContent(savedContent) having landed by the time it ran — a real race, not a guarantee: React commits that render on its own schedule, and if the correction's continuation runs first, onSave still reads the ambient (still-dirty) content ref and re-persists the discarded edit. onSave now accepts an optional override content; discard() captures savedContentRef.current as an explicit target at the moment it's called and passes it through, so the correction always pushes the true reverted baseline regardless of render timing. Widened the shared onSave type is backward compatible — the other useAutosave caller (chunk-editor) ignores the extra optional param. * fix(files): retry no longer depends on a remount-able shared ref, purge stale drafts Two more from round 6: - The failure toast's Retry action captured saveRef.current inside the effect reacting to saveStatus='error' — but if the user switched files between the failure occurring and that effect committing, the keyed remount could have already repointed saveRef at the new file's save function first. onSaveStatusChange now passes the failing instance's own saveImmediately alongside the 'error' status directly from the hook that owns it, so retry can never be sourced from the wrong file regardless of remount timing. - A local draft with a stale (mismatched) baseline was left in IndexedDB after being correctly skipped for recovery, so it could resurrect later if the server baseline ever coincidentally matched it again. It's now purged as soon as it's identified as stale. * fix(files): clear inFlightRef once a save settles inFlightRef.current was never reset after a save resolved or rejected — it stayed pointing at the (now-fulfilled) promise indefinitely. discard() reads it to decide whether a save is genuinely in flight; since a resolved promise is still truthy, discard() treated any prior completed save as still pending, captured savedContentRef.current as the "corrective" target, and could push a stale baseline if that capture happened before the save's own dispatch had updated it. Now cleared to null as soon as the save settles, so discard()'s in-flight check reflects reality regardless of how long ago the last save finished. * fix(files): surface a failed discard correction, resume autosave after discard if editing continues Two more from round 8: - If discard()'s corrective save failed, it was only logged — the server could permanently keep the discarded edit with zero user-facing signal. Now surfaced via a dedicated onDiscardCorrectionFailed callback, which closes over the specific file's own name rather than routing through the shared onSaveStatusChange path (that path reads whichever file is currently selected, which by the time this fires is already the file the user navigated to, not the discarded one). - discardedRef never cleared once set, so if the editor stayed mounted briefly after discard (before navigation completes) and the user typed again, every save path silently no-op'd forever for that new edit too. It now clears itself as soon as a genuinely new edit (content diverging from savedContent again) is observed. * fix(files): serialize local drafts by key across mounts, not just within one idbQueueRef was a per-instance ref, so it only ordered IndexedDB ops issued by the same hook instance. A slow write queued by an unmount's flush lived on as a bare promise after that instance was gone, with nothing sequencing it against a freshly-mounted instance for the same file — its del() or recovery read could run first, and the late write would land afterward and resurrect a draft that was supposed to be gone. Replaced the per-instance ref with a module-level queue keyed by draft key, shared by every useAutosave instance (past or present) touching that key, so ordering holds across a fast unmount+remount of the same file. * improvement(files): consolidate autosave hook after 9 rounds of incremental fixes Cosmetic-only pass, no behavior change: - Hoisted MIN_SAVING_DISPLAY_MS to module scope alongside LOCAL_DRAFT_DELAY_MS (was declared inside the hook body, re-allocated every render, inconsistent with its sibling constant). - Grouped the ~15 refs by concern (save/network, content mirrors, draft-key + callbacks, local-draft persistence, discard) instead of the chronological order they were added across nine review rounds. - Removed a provably-dead re-check in the unmount cleanup: content/savedContent can't change between the outer guard and the inner one (no renders happen post-unmount), so only the discardedRef half of the inner check was live. - Removed an unnecessary useCallback around onDiscardCorrectionFailed — its reference is never observed by anything (useAutosave copies it into a ref every render regardless of identity), unlike handleSaveStatusChange in files.tsx, which is correctly memoized because it flows through React.memo-wrapped TextEditor/RichMarkdownEditor. Validated against external research: the two-tier debounce (network + local IndexedDB draft) matches how Tiptap/Notion describe their own local-first persistence; the discardedRef+corrective-save approach over AbortController is a deliberate, justified choice (onSave has no signal parameter, and an abort can't undo a write that's already landed server-side); the module-level per-key promise queue is a recognized idiomatic pattern. Splitting this hook into three smaller ones (useDebouncedSave/useLocalDraft/useDiscard) is a legitimate future refactor, deliberately deferred given the risk of touching this heavily-interdependent, already-hardened state this late in review. * fix(files): serialize discard correction against newer saves, recover local drafts only once per mount Two more real races, both interactions between earlier fixes: - discard()'s corrective save and a genuinely new edit made right after could race independently: if the user typed again before the correction fired, and that correction landed after the new edit's own save, the server would end up with the discarded baseline instead of the user's latest content. The correction now shares the same inFlightRef/savingRef mutual exclusion normal saves use, and skips entirely once content has moved on to something that's neither the discarded baseline nor what it was at the moment discard() was called. - The local-draft recovery effect re-ran every time draftKey toggled through enabled (e.g. autosave turning off during agent streaming and back on once it settles), re-scanning IndexedDB as if freshly mounted. If the settled content coincidentally matched the stale draft's stored baseline, a pre-stream local edit could silently overwrite the agent's work. Recovery now attempts exactly once per mount.
This commit is contained in:
@@ -99,8 +99,12 @@ interface FileViewerProps {
|
||||
previewMode?: PreviewMode
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
onSaveStatusChange?: (
|
||||
status: 'idle' | 'saving' | 'saved' | 'error',
|
||||
retry?: () => Promise<void>
|
||||
) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
discardRef?: React.MutableRefObject<(() => void) | null>
|
||||
streamingContent?: string
|
||||
isAgentEditing?: boolean
|
||||
streamIsIncremental?: boolean
|
||||
@@ -131,6 +135,7 @@ function FileViewerContent({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
streamingContent,
|
||||
isAgentEditing,
|
||||
streamIsIncremental,
|
||||
@@ -174,6 +179,7 @@ function FileViewerContent({
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
discardRef={discardRef}
|
||||
streamingContent={streamingContent}
|
||||
isAgentEditing={isAgentEditing}
|
||||
streamIsIncremental={streamIsIncremental}
|
||||
@@ -193,6 +199,7 @@ function FileViewerContent({
|
||||
onDirtyChange={onDirtyChange}
|
||||
onSaveStatusChange={onSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
discardRef={discardRef}
|
||||
streamingContent={streamingContent}
|
||||
isAgentEditing={isAgentEditing}
|
||||
disableStreamingAutoScroll={disableStreamingAutoScroll}
|
||||
|
||||
+14
-9
@@ -45,8 +45,9 @@ interface RichMarkdownEditorProps {
|
||||
canEdit: boolean
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: SaveStatus) => void
|
||||
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
discardRef?: React.MutableRefObject<(() => void) | null>
|
||||
streamingContent?: string
|
||||
isAgentEditing?: boolean
|
||||
/**
|
||||
@@ -70,6 +71,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
streamingContent,
|
||||
isAgentEditing,
|
||||
streamIsIncremental,
|
||||
@@ -93,6 +95,7 @@ export const RichMarkdownEditor = memo(function RichMarkdownEditor({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
normalizeBaseline: normalizeMarkdownContent,
|
||||
})
|
||||
|
||||
@@ -356,6 +359,14 @@ export function LoadedRichMarkdownEditor({
|
||||
const lastStreamParseAtRef = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const syncEditorBody = (body: string) => {
|
||||
if (body === lastSyncedBodyRef.current) return
|
||||
lastSyncedBodyRef.current = body
|
||||
editor.commands.setContent(parseMarkdownToDoc(body), {
|
||||
contentType: 'json',
|
||||
emitUpdate: false,
|
||||
})
|
||||
}
|
||||
if (isStreaming) {
|
||||
wasStreamingRef.current = true
|
||||
if (editor.isEditable) editor.setEditable(false)
|
||||
@@ -407,14 +418,7 @@ export function LoadedRichMarkdownEditor({
|
||||
if (isInitialSettle || wasStreamingRef.current) {
|
||||
wasStreamingRef.current = false
|
||||
settledRef.current = lockSettled(content)
|
||||
const body = splitFrontmatter(content).body
|
||||
if (body !== lastSyncedBodyRef.current) {
|
||||
lastSyncedBodyRef.current = body
|
||||
editor.commands.setContent(parseMarkdownToDoc(body), {
|
||||
contentType: 'json',
|
||||
emitUpdate: false,
|
||||
})
|
||||
}
|
||||
syncEditorBody(splitFrontmatter(content).body)
|
||||
// `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a
|
||||
// select-all survives as "select everything," permanently painting every divider/image with the
|
||||
// `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run
|
||||
@@ -428,6 +432,7 @@ export function LoadedRichMarkdownEditor({
|
||||
if (isInitialSettle && autoFocus) editor.commands.focus('end')
|
||||
return
|
||||
}
|
||||
syncEditorBody(splitFrontmatter(content).body)
|
||||
if (settledRef.current) editor.setEditable(canEdit && settledRef.current.verdict)
|
||||
}, [editor, content, isStreaming, canEdit, autoFocus, disableStreamingAutoScroll])
|
||||
|
||||
|
||||
@@ -328,8 +328,12 @@ interface TextEditorProps {
|
||||
previewMode: PreviewMode
|
||||
autoFocus?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
|
||||
onSaveStatusChange?: (
|
||||
status: 'idle' | 'saving' | 'saved' | 'error',
|
||||
retry?: () => Promise<void>
|
||||
) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
discardRef?: React.MutableRefObject<(() => void) | null>
|
||||
streamingContent?: string
|
||||
isAgentEditing?: boolean
|
||||
disableStreamingAutoScroll: boolean
|
||||
@@ -345,6 +349,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
streamingContent,
|
||||
isAgentEditing,
|
||||
disableStreamingAutoScroll,
|
||||
@@ -385,6 +390,7 @@ export const TextEditor = memo(function TextEditor({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
})
|
||||
contentRef.current = content
|
||||
|
||||
|
||||
+44
-10
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import {
|
||||
useUpdateWorkspaceFileContent,
|
||||
@@ -34,8 +35,11 @@ interface UseEditableFileContentOptions {
|
||||
streamingContent?: string
|
||||
isAgentEditing?: boolean
|
||||
onDirtyChange?: (isDirty: boolean) => void
|
||||
onSaveStatusChange?: (status: SaveStatus) => void
|
||||
/** `retry` is this instance's own `saveImmediately`, passed alongside an `'error'` status so a caller-side retry never depends on a shared, remount-able ref. */
|
||||
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
|
||||
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
|
||||
/** Bridges an imperative "discard the current draft" command up to the caller, mirroring `saveRef`. */
|
||||
discardRef?: React.MutableRefObject<(() => void) | null>
|
||||
/**
|
||||
* Optional transform applied to the fetched content before it becomes the editor's baseline. A
|
||||
* surface whose editor re-serializes its content to a canonical form (the rich markdown editor)
|
||||
@@ -115,6 +119,7 @@ export function useEditableFileContent({
|
||||
onDirtyChange,
|
||||
onSaveStatusChange,
|
||||
saveRef,
|
||||
discardRef,
|
||||
normalizeBaseline,
|
||||
}: UseEditableFileContentOptions): EditableFileContent {
|
||||
const onDirtyChangeRef = useRef(onDirtyChange)
|
||||
@@ -182,17 +187,28 @@ export function useEditableFileContent({
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
const next = contentRef.current
|
||||
await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next })
|
||||
markSavedContent(next)
|
||||
}, [workspaceId, file.id, markSavedContent])
|
||||
const onSave = useCallback(
|
||||
async (overrideContent?: string) => {
|
||||
const next = overrideContent ?? contentRef.current
|
||||
await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: next })
|
||||
markSavedContent(next)
|
||||
},
|
||||
[workspaceId, file.id, markSavedContent]
|
||||
)
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty } = useAutosave({
|
||||
const autosaveEnabled = canEdit && isInitialized && !isStreamInteractionLocked
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty, discard } = useAutosave({
|
||||
content,
|
||||
savedContent,
|
||||
onSave,
|
||||
enabled: canEdit && isInitialized && !isStreamInteractionLocked,
|
||||
enabled: autosaveEnabled,
|
||||
draftKey: autosaveEnabled ? `${workspaceId}:${file.id}` : undefined,
|
||||
onRestoreDraft: setDraftContent,
|
||||
onDiscardCorrectionFailed: () =>
|
||||
toast.error(
|
||||
`Failed to discard "${file.name}" — the server may still have the discarded edit`
|
||||
),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,8 +216,11 @@ export function useEditableFileContent({
|
||||
}, [isDirty])
|
||||
|
||||
useEffect(() => {
|
||||
onSaveStatusChangeRef.current?.(saveStatus)
|
||||
}, [saveStatus])
|
||||
onSaveStatusChangeRef.current?.(
|
||||
saveStatus,
|
||||
saveStatus === 'error' ? saveImmediately : undefined
|
||||
)
|
||||
}, [saveStatus, saveImmediately])
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveRef) return
|
||||
@@ -213,6 +232,21 @@ export function useEditableFileContent({
|
||||
}
|
||||
}, [saveImmediately, saveRef])
|
||||
|
||||
const discardChanges = useCallback(() => {
|
||||
discard()
|
||||
setDraftContent(savedContent)
|
||||
}, [discard, setDraftContent, savedContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!discardRef) return
|
||||
discardRef.current = discardChanges
|
||||
return () => {
|
||||
if (discardRef.current === discardChanges) {
|
||||
discardRef.current = null
|
||||
}
|
||||
}
|
||||
}, [discardChanges, discardRef])
|
||||
|
||||
return {
|
||||
content: displayContent,
|
||||
setDraftContent,
|
||||
|
||||
@@ -171,6 +171,7 @@ function formatFileType(mimeType: string | null, filename: string): string {
|
||||
export function Files() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const saveRef = useRef<(() => Promise<void>) | null>(null)
|
||||
const discardRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
@@ -970,6 +971,15 @@ export function Files() {
|
||||
await saveRef.current()
|
||||
}, [])
|
||||
|
||||
const handleSaveStatusChange = useCallback((status: SaveStatus, retry?: () => Promise<void>) => {
|
||||
setSaveStatus(status)
|
||||
if (status === 'error') {
|
||||
toast.error(`Failed to save "${selectedFileRef.current?.name ?? 'file'}"`, {
|
||||
action: { label: 'Retry', onClick: () => void retry?.() },
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNavigateFromFileDetail = useCallback(
|
||||
(url: string) => {
|
||||
if (isDirtyRef.current) {
|
||||
@@ -1106,6 +1116,7 @@ export function Files() {
|
||||
])
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
discardRef.current?.()
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
@@ -1358,16 +1369,8 @@ export function Files() {
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirtyRef.current) return
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave])
|
||||
|
||||
const selectedRowIdsRef = useRef(selectedRowIds)
|
||||
@@ -1428,43 +1431,21 @@ export function Files() {
|
||||
const fileActions = useMemo<ResourceAction[]>(() => {
|
||||
if (!selectedFile) return []
|
||||
// A large CSV renders as a read-only streamed preview (no editor), so it gets neither the
|
||||
// Save action nor the edit/split/preview toggle — just like a non-editable file.
|
||||
// edit/split/preview toggle nor autosave — just like a non-editable file.
|
||||
const streamOnly = isCsvStreamOnly(selectedFile)
|
||||
const canEditText = isTextEditable(selectedFile) && !streamOnly
|
||||
const canPreview = isPreviewable(selectedFile) && !streamOnly
|
||||
// Markdown renders in the single-surface inline editor, which has no raw/split/preview
|
||||
// modes — so it keeps Save but drops the mode toggle.
|
||||
// Markdown renders in the single-surface inline editor, which has no raw/split/preview modes.
|
||||
const isInlineMarkdown = isMarkdownFile(selectedFile)
|
||||
const hasSplitView = canEditText && canPreview && !isInlineMarkdown
|
||||
const showPreviewToggle = canPreview && !isInlineMarkdown
|
||||
|
||||
const saveLabel =
|
||||
saveStatus === 'saving'
|
||||
? 'Saving...'
|
||||
: saveStatus === 'saved'
|
||||
? 'Saved'
|
||||
: saveStatus === 'error'
|
||||
? 'Save failed'
|
||||
: 'Save'
|
||||
|
||||
const nextModeLabel =
|
||||
previewMode === 'editor' ? 'Split' : previewMode === 'split' ? 'Preview' : 'Edit'
|
||||
const nextModeIcon =
|
||||
previewMode === 'editor' ? Columns2 : previewMode === 'split' ? Eye : Pencil
|
||||
|
||||
return [
|
||||
...(canEditText
|
||||
? [
|
||||
{
|
||||
text: saveLabel,
|
||||
onSelect: handleSave,
|
||||
disabled:
|
||||
(!isDirty && saveStatus === 'idle') ||
|
||||
saveStatus === 'saving' ||
|
||||
saveStatus === 'saved',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasSplitView
|
||||
? [
|
||||
{
|
||||
@@ -1505,12 +1486,9 @@ export function Files() {
|
||||
}, [
|
||||
selectedFile,
|
||||
canEdit,
|
||||
saveStatus,
|
||||
previewMode,
|
||||
isDirty,
|
||||
handleCyclePreviewMode,
|
||||
handleTogglePreview,
|
||||
handleSave,
|
||||
handleDownloadSelected,
|
||||
handleShareSelected,
|
||||
handleDeleteSelected,
|
||||
@@ -1897,8 +1875,9 @@ export function Files() {
|
||||
previewMode={previewMode}
|
||||
autoFocus={isNewFile || justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
onSaveStatusChange={setSaveStatus}
|
||||
onSaveStatusChange={handleSaveStatusChange}
|
||||
saveRef={saveRef}
|
||||
discardRef={discardRef}
|
||||
/>
|
||||
|
||||
<ChipConfirmModal
|
||||
|
||||
@@ -4,20 +4,40 @@
|
||||
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: () => Promise<void>
|
||||
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
|
||||
}
|
||||
@@ -31,7 +51,12 @@ function renderAutosave(initial: ProbeProps): { handle: HookHandle; props: Probe
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
const props = { ...initial }
|
||||
let latest = { saveStatus: 'idle' as SaveStatus, isDirty: false, saveImmediately: async () => {} }
|
||||
let latest = {
|
||||
saveStatus: 'idle' as SaveStatus,
|
||||
isDirty: false,
|
||||
saveImmediately: async () => {},
|
||||
discard: () => {},
|
||||
}
|
||||
|
||||
function Probe(p: ProbeProps) {
|
||||
latest = useAutosave(p)
|
||||
@@ -49,6 +74,7 @@ function renderAutosave(initial: ProbeProps): { handle: HookHandle; props: Probe
|
||||
status: () => latest.saveStatus,
|
||||
isDirty: () => latest.isDirty,
|
||||
saveImmediately: () => latest.saveImmediately(),
|
||||
discard: () => latest.discard(),
|
||||
rerender: (next) => {
|
||||
Object.assign(props, next)
|
||||
render()
|
||||
@@ -69,6 +95,7 @@ async function flush() {
|
||||
describe('useAutosave', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
fakeDraftStore.clear()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
@@ -257,6 +284,28 @@ describe('useAutosave', () => {
|
||||
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 })
|
||||
@@ -264,4 +313,538 @@ describe('useAutosave', () => {
|
||||
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('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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+191
-10
@@ -1,21 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { del, get, set } from 'idb-keyval'
|
||||
|
||||
const logger = createLogger('Autosave')
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface LocalDraft {
|
||||
content: string
|
||||
savedContent: string
|
||||
}
|
||||
|
||||
const LOCAL_DRAFT_DELAY_MS = 400
|
||||
const MIN_SAVING_DISPLAY_MS = 600
|
||||
|
||||
function localDraftDbKey(draftKey: string) {
|
||||
return `autosave-draft:${draftKey}`
|
||||
}
|
||||
|
||||
const draftOpQueues = new Map<string, Promise<unknown>>()
|
||||
|
||||
/** Serializes IndexedDB reads/writes for a given draft key across every `useAutosave` instance (including one that already unmounted), so a late write from a just-unmounted instance can't land after a newly-mounted instance's delete or recovery read. */
|
||||
function enqueueDraftOp<T>(key: string, op: () => Promise<T>): Promise<T> {
|
||||
const prev = draftOpQueues.get(key) ?? Promise.resolve()
|
||||
const result = prev.then(op, op)
|
||||
const settled = result.catch(() => {})
|
||||
draftOpQueues.set(key, settled)
|
||||
void settled.then(() => {
|
||||
if (draftOpQueues.get(key) === settled) draftOpQueues.delete(key)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
interface UseAutosaveOptions {
|
||||
content: string
|
||||
savedContent: string
|
||||
onSave: () => Promise<void>
|
||||
/** `overrideContent`, when passed, is what `discard()`'s corrective save pushes — the reverted baseline captured at discard time, not whatever the ambient content ref reads by the time the in-flight save it's correcting for has settled. */
|
||||
onSave: (overrideContent?: string) => Promise<void>
|
||||
delay?: number
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Uniquely identifies the document being edited (e.g. a file id). When set, the draft is
|
||||
* mirrored into IndexedDB on a short debounce, independent of the network save, and recovered
|
||||
* via `onRestoreDraft` on mount if newer than `savedContent`.
|
||||
*/
|
||||
draftKey?: string
|
||||
onRestoreDraft?: (content: string) => void
|
||||
/** Called if `discard()`'s corrective save fails — the only way that failure can surface, since it happens after the component may already have unmounted. */
|
||||
onDiscardCorrectionFailed?: () => void
|
||||
}
|
||||
|
||||
interface UseAutosaveReturn {
|
||||
saveStatus: SaveStatus
|
||||
saveImmediately: () => Promise<void>
|
||||
isDirty: boolean
|
||||
/** Abandons the current draft: blocks any save/local-draft write not yet started, clears the local draft immediately, and corrects the server if a save already in flight lands afterward. */
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,29 +71,77 @@ export function useAutosave({
|
||||
onSave,
|
||||
delay = 1500,
|
||||
enabled = true,
|
||||
draftKey,
|
||||
onRestoreDraft,
|
||||
onDiscardCorrectionFailed,
|
||||
}: UseAutosaveOptions): UseAutosaveReturn {
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const displayTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const savingRef = useRef(false)
|
||||
const savingStartRef = useRef(0)
|
||||
const inFlightRef = useRef<Promise<void> | null>(null)
|
||||
const unmountedRef = useRef(false)
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
const enabledRef = useRef(enabled)
|
||||
enabledRef.current = enabled
|
||||
|
||||
const savedContentRef = useRef(savedContent)
|
||||
savedContentRef.current = savedContent
|
||||
const contentRef = useRef(content)
|
||||
contentRef.current = content
|
||||
|
||||
const effectiveDraftKey = enabled ? draftKey : undefined
|
||||
const draftKeyRef = useRef(effectiveDraftKey)
|
||||
draftKeyRef.current = effectiveDraftKey
|
||||
const onRestoreDraftRef = useRef(onRestoreDraft)
|
||||
onRestoreDraftRef.current = onRestoreDraft
|
||||
const onDiscardCorrectionFailedRef = useRef(onDiscardCorrectionFailed)
|
||||
onDiscardCorrectionFailedRef.current = onDiscardCorrectionFailed
|
||||
|
||||
const localDraftTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const lastPersistedContentRef = useRef<string | null>(null)
|
||||
|
||||
const discardedRef = useRef(false)
|
||||
|
||||
const isDirty = content !== savedContent
|
||||
const savingStartRef = useRef(0)
|
||||
const inFlightRef = useRef<Promise<void> | null>(null)
|
||||
const unmountedRef = useRef(false)
|
||||
const MIN_SAVING_DISPLAY_MS = 600
|
||||
if (discardedRef.current && isDirty) discardedRef.current = false
|
||||
|
||||
const persistLocalDraft = useCallback(() => {
|
||||
const key = draftKeyRef.current
|
||||
if (discardedRef.current || !key || contentRef.current === savedContentRef.current) return
|
||||
if (contentRef.current === lastPersistedContentRef.current) return
|
||||
const content = contentRef.current
|
||||
const savedContentSnapshot = savedContentRef.current
|
||||
void enqueueDraftOp(key, () =>
|
||||
set(localDraftDbKey(key), {
|
||||
content,
|
||||
savedContent: savedContentSnapshot,
|
||||
} satisfies LocalDraft)
|
||||
)
|
||||
.then(() => {
|
||||
lastPersistedContentRef.current = content
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('IndexedDB draft write failed', { key, error })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearLocalDraft = useCallback(() => {
|
||||
const key = draftKeyRef.current
|
||||
lastPersistedContentRef.current = null
|
||||
if (!key) return
|
||||
void enqueueDraftOp(key, () => del(localDraftDbKey(key))).catch((error) => {
|
||||
logger.warn('IndexedDB draft delete failed', { key, error })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (
|
||||
discardedRef.current ||
|
||||
!enabledRef.current ||
|
||||
savingRef.current ||
|
||||
contentRef.current === savedContentRef.current
|
||||
@@ -68,6 +158,7 @@ export function useAutosave({
|
||||
} catch {
|
||||
nextStatus = 'error'
|
||||
} finally {
|
||||
inFlightRef.current = null
|
||||
if (unmountedRef.current) {
|
||||
savingRef.current = false
|
||||
} else {
|
||||
@@ -106,23 +197,113 @@ export function useAutosave({
|
||||
clearTimeout(timerRef.current)
|
||||
clearTimeout(idleTimerRef.current)
|
||||
clearTimeout(displayTimerRef.current)
|
||||
if (!enabledRef.current || contentRef.current === savedContentRef.current) return
|
||||
clearTimeout(localDraftTimerRef.current)
|
||||
persistLocalDraft()
|
||||
if (
|
||||
discardedRef.current ||
|
||||
!enabledRef.current ||
|
||||
contentRef.current === savedContentRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Flush the latest content on unmount, but chain it AFTER any in-flight save rather than
|
||||
// firing a concurrent PUT: the in-flight save captured an older snapshot, so writing the
|
||||
// latest sequentially (last) prevents an out-of-order completion from clobbering it.
|
||||
void (async () => {
|
||||
await inFlightRef.current
|
||||
if (contentRef.current !== savedContentRef.current) {
|
||||
await onSaveRef.current().catch(() => {})
|
||||
if (!discardedRef.current) {
|
||||
await onSaveRef.current().then(clearLocalDraft, () => {})
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [])
|
||||
}, [clearLocalDraft, persistLocalDraft])
|
||||
|
||||
const wasDirtyRef = useRef(isDirty)
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveDraftKey && !isDirty && wasDirtyRef.current) clearLocalDraft()
|
||||
wasDirtyRef.current = isDirty
|
||||
}, [effectiveDraftKey, isDirty, clearLocalDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftKey || !isDirty) return
|
||||
clearTimeout(localDraftTimerRef.current)
|
||||
localDraftTimerRef.current = setTimeout(persistLocalDraft, LOCAL_DRAFT_DELAY_MS)
|
||||
return () => clearTimeout(localDraftTimerRef.current)
|
||||
}, [content, effectiveDraftKey, isDirty, persistLocalDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftKey) return
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') persistLocalDraft()
|
||||
}
|
||||
window.addEventListener('pagehide', persistLocalDraft)
|
||||
document.addEventListener('visibilitychange', handleVisibility)
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', persistLocalDraft)
|
||||
document.removeEventListener('visibilitychange', handleVisibility)
|
||||
}
|
||||
}, [effectiveDraftKey, persistLocalDraft])
|
||||
|
||||
const recoveryAttemptedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftKey || recoveryAttemptedRef.current) return
|
||||
recoveryAttemptedRef.current = true
|
||||
let cancelled = false
|
||||
void enqueueDraftOp(effectiveDraftKey, () =>
|
||||
get<LocalDraft>(localDraftDbKey(effectiveDraftKey))
|
||||
)
|
||||
.then((draft) => {
|
||||
if (cancelled || !draft) return
|
||||
if (draft.savedContent !== savedContentRef.current) {
|
||||
clearLocalDraft()
|
||||
return
|
||||
}
|
||||
if (draft.content === draft.savedContent) return
|
||||
if (contentRef.current !== savedContentRef.current) return
|
||||
onRestoreDraftRef.current?.(draft.content)
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('IndexedDB draft read failed', { draftKey: effectiveDraftKey, error })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [effectiveDraftKey, clearLocalDraft])
|
||||
|
||||
const saveImmediately = useCallback(async () => {
|
||||
clearTimeout(timerRef.current)
|
||||
await save()
|
||||
}, [save])
|
||||
|
||||
return { saveStatus, saveImmediately, isDirty }
|
||||
const discard = useCallback(() => {
|
||||
discardedRef.current = true
|
||||
clearTimeout(timerRef.current)
|
||||
clearTimeout(localDraftTimerRef.current)
|
||||
clearLocalDraft()
|
||||
const pendingSave = inFlightRef.current
|
||||
if (!pendingSave) return
|
||||
const target = savedContentRef.current
|
||||
const contentAtDiscard = contentRef.current
|
||||
void pendingSave.then(() => {
|
||||
const current = contentRef.current
|
||||
if (inFlightRef.current || (current !== target && current !== contentAtDiscard)) return
|
||||
savingRef.current = true
|
||||
const correctionRun = onSaveRef
|
||||
.current(target)
|
||||
.catch((error) => {
|
||||
logger.warn('Corrective save after discard failed', { error })
|
||||
onDiscardCorrectionFailedRef.current?.()
|
||||
})
|
||||
.finally(() => {
|
||||
savingRef.current = false
|
||||
inFlightRef.current = null
|
||||
})
|
||||
inFlightRef.current = correctionRun
|
||||
return correctionRun
|
||||
})
|
||||
}, [clearLocalDraft])
|
||||
|
||||
return { saveStatus, saveImmediately, isDirty, discard }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user