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
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
'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
|
|
/** `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
|
|
}
|
|
|
|
/**
|
|
* Shared autosave hook that debounces content changes and persists them automatically.
|
|
* Keeps Cmd+S / Save button working via `saveImmediately`, and flushes on unmount
|
|
* so edits aren't lost when navigating away.
|
|
*/
|
|
export function useAutosave({
|
|
content,
|
|
savedContent,
|
|
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 discardTargetRef = useRef<string | null>(null)
|
|
const failedCorrectionTargetRef = useRef<string | null>(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
|
|
failedCorrectionTargetRef.current = null
|
|
}
|
|
|
|
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
|
|
) {
|
|
return
|
|
}
|
|
savingRef.current = true
|
|
savingStartRef.current = Date.now()
|
|
if (!unmountedRef.current) setSaveStatus('saving')
|
|
const run = (async () => {
|
|
let nextStatus: SaveStatus = 'saved'
|
|
try {
|
|
await onSaveRef.current()
|
|
} catch {
|
|
nextStatus = 'error'
|
|
} finally {
|
|
inFlightRef.current = null
|
|
if (unmountedRef.current) {
|
|
savingRef.current = false
|
|
} else {
|
|
const elapsed = Date.now() - savingStartRef.current
|
|
const remaining = Math.max(0, MIN_SAVING_DISPLAY_MS - elapsed)
|
|
displayTimerRef.current = setTimeout(() => {
|
|
// 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) {
|
|
save()
|
|
}
|
|
}, remaining)
|
|
}
|
|
}
|
|
})()
|
|
inFlightRef.current = run
|
|
await run
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !isDirty || savingRef.current) return
|
|
clearTimeout(timerRef.current)
|
|
timerRef.current = setTimeout(save, delay)
|
|
return () => clearTimeout(timerRef.current)
|
|
}, [content, enabled, isDirty, delay, save])
|
|
|
|
useEffect(() => {
|
|
// Reset on every (re)mount, not only set on unmount: React strict mode runs effects
|
|
// mount → cleanup → mount, so without this the flag would stay `true` after the dev
|
|
// double-invoke and permanently suppress the "saving"/"saved" status updates below.
|
|
unmountedRef.current = false
|
|
return () => {
|
|
unmountedRef.current = true
|
|
clearTimeout(timerRef.current)
|
|
clearTimeout(idleTimerRef.current)
|
|
clearTimeout(displayTimerRef.current)
|
|
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 (!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 recoveredForKeyRef = useRef<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!effectiveDraftKey || recoveredForKeyRef.current === effectiveDraftKey) return
|
|
recoveredForKeyRef.current = effectiveDraftKey
|
|
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])
|
|
|
|
/** 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')
|
|
}
|
|
)
|
|
.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
|
|
},
|
|
[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, runCorrection])
|
|
|
|
return { saveStatus, saveImmediately, isDirty, discard }
|
|
}
|