import { useCallback, useContext, useEffect, useId, useRef, useState } from "react"; import { QueryClientContext } from "@tanstack/react-query"; import { bindAccount } from "@/lib/account-scope"; import { classifyError, describeError, type ErrorDescription } from "@/api/errors"; import { loadStructuredDraft, saveStructuredDraft } from "@/lib/composer-draft"; import { useConnectivityStore } from "@/lib/connectivity"; import { classifySendFailure, MAX_ONLINE_RESENDS, resendDelayFor, type SendOutcome } from "@/lib/pending-send"; import { useAutoResend } from "./useDurableSubmit"; import { useDraftOwner, useDraftOwnerReader } from "./useDraftOwner"; import { trackPendingWrite } from "@/lib/reload-safety"; import { hasUnverifiedSession } from "@/lib/cached-session"; export type DurableAutosaveStatus = | "idle" | "saving" | "saved" /** Kept locally; it saves when the connection allows. */ | "waiting" | "stalled" | "error"; export type DurableAutosaveResult = | { kind: "saved" } | { kind: "waiting" } /** A definitive failure; the caller may handle it (a conflict view, a lock notice). */ | { kind: "failed"; error: unknown }; /** Copy for the autosave indicator. */ export function autosaveLabel(status: DurableAutosaveStatus, error: ErrorDescription | null): string | null { switch (status) { case "saving": return "Saving…"; case "saved": return "Saved"; case "waiting": return "Will save when reconnected"; case "stalled": return "Automatic saving paused"; case "error": return error ? `Couldn't save (${error.body.replace(/\.$/, "")})` : "Couldn't save"; default: return null; } } export interface DurableAutosaveOptions { sourceId: string; targetKey?: string; /** Writes `draft`. Only absolute-value writes (PUT a body, PATCH a field) are safe to retry. */ save: (draft: T) => Promise; /** * A 409 can be a replay after a lost response: our earlier save landed, so * the retry's base revision is stale. Return true when the server already * holds `draft`; the save then counts as done. */ matchesServer?: (error: unknown, draft: T) => Promise | boolean; /** Persist unsaved drafts here (localStorage); null keeps them in memory. */ storageKey?: string | null; /** For error copy: "save the document". */ action?: string; } /** Per-save overrides: a caller can bind the request and the 409 check to one draft. */ export interface DurableAutosaveJob { /** Revalidate a restored draft against fresh data before every write attempt. */ beforeSave?: (draft: T) => Promise; save?: (draft: T) => Promise; matchesServer?: (error: unknown, draft: T) => Promise | boolean; /** * A background retry of this draft failed definitively. `run` returns that * outcome to its caller, but nobody awaits an automatic retry. */ onRetryFailed?: (error: unknown) => void; } export interface DurableAutosave { status: DurableAutosaveStatus; error: ErrorDescription | null; label: string | null; /** Save now. Never rejects: failures become a status, so no rejection goes unhandled. */ run: (draft: T, job?: DurableAutosaveJob) => Promise; /** The draft changed; drop a stale "Saved" or error. */ markDirty: () => void; /** Release an unchanged draft without dismissing the last successful save. */ clearDirty: () => void; /** Forget pending work and go back to idle (the user cancelled or reverted). */ reset: () => void; /** Write the current draft to storage so a reload or crash keeps it. */ persistDraft: (draft: T) => void; /** An unsaved draft from an earlier session, if any. */ readStoredDraft: () => T | null; clearStoredDraft: () => void; resendNow: () => void; } type AutosaveRevision = { latest: symbol; active: Set }; const latestAutosaves = new WeakMap>(); function autosaveRegistry(store: object): Map { let registry = latestAutosaves.get(store); if (!registry) { registry = new Map(); latestAutosaves.set(store, registry); } return registry; } const SAVING_DELAY_MS = 250; const SAVED_LINGER_MS = 1600; /** * Autosave that does not lose text across outages: * * - The draft can be persisted on every change (`persistDraft`) and is * cleared only after a confirmed save. * - A transient failure keeps the draft and saves it again with backoff and * right after the connection recovers. While the app is offline it does not * send at all. * - A 409 whose server copy equals our draft counts as saved. * - Errors become readable copy for "Couldn't save (reason)". */ export function useDurableAutosave(options: DurableAutosaveOptions): DurableAutosave { const client = useContext(QueryClientContext); const owner = useDraftOwner(); const readOwner = useDraftOwnerReader(); const instanceId = useId(); const detachedWork = useRef<() => void>(() => {}); const detachedWake = useRef<(() => void) | null>(null); const ownerRef = useRef(owner); ownerRef.current = owner; const optionsRef = useRef(options); optionsRef.current = options; const targetKey = options.targetKey ?? options.sourceId; const targetRef = useRef(targetKey); targetRef.current = targetKey; const dirtyWrites = useRef(new Map void }>()); const releaseDirty = (target: string, version: symbol | undefined) => { const dirty = dirtyWrites.current.get(target); if (dirty && dirty.version === version) { dirtyWrites.current.delete(target); dirty.release(); } }; useEffect(() => () => { // A target change cancels its unused editor debounce. Admitted jobs keep // their independent pending-write tracking until they settle. releaseDirty(targetKey, dirtyWrites.current.get(targetKey)?.version); }, [targetKey]); type Job = { token: symbol; dirtyVersion?: symbol; registryKey: string; resends: number; saving?: boolean; id: number; target: string; owner: string | undefined; account: ReturnType | null; draft: T; beforeSave?: (draft: T) => Promise; save: (draft: T) => Promise; matchesServer?: DurableAutosaveOptions["matchesServer"]; onRetryFailed?: DurableAutosaveJob["onRetryFailed"]; storageKey: string | null; action: string }; const jobs = useRef(new Map()); const generations = useRef(new Map()); const sequence = useRef(0); const [, refresh] = useState(0); type View = { target: string; status: DurableAutosaveStatus; error: ErrorDescription | null }; const [view, setViewState] = useState({ target: targetKey, status: "idle", error: null }); const setView = useCallback((next: View | ((current: View) => View)) => { setViewState(current => { const value = typeof next === "function" ? next(current) : next; return current.target === value.target && current.status === value.status && current.error === value.error ? current : value; }); }, []); const store = useConnectivityStore(); // Waiting work must remain visible to reload safety before React renders // the next retry state, including the interval between network attempts. const reportPendingJobs = () => { if (mounted.current) store.setPendingWrites(optionsRef.current.sourceId, jobs.current.size); }; const registry = autosaveRegistry(store); const releaseJob = (job: Job) => { const entry = registry.get(job.registryKey); entry?.active.delete(job.token); if (entry && !entry.active.size) registry.delete(job.registryKey); }; // Resolve first identity from the same generation guard used by mutations. // This also runs after unmount, when React cannot migrate the job for us. function owned(job: Job) { if (job.account && !job.account.isCurrentScope()) return false; const current = readOwner(); if (job.owner === undefined && current !== undefined) { const nextKey = JSON.stringify([current, job.target]); if (registry.get(job.registryKey)?.latest !== job.token || registry.has(nextKey)) return false; releaseJob(job); job.owner = current; job.registryKey = nextKey; registry.set(nextKey, { latest: job.token, active: new Set([job.token]) }); } return current === job.owner && generations.current.get(job.target) === job.id && registry.get(job.registryKey)?.latest === job.token; } const mounted = useRef(true); const savingTimer = useRef | null>(null); const savedTimer = useRef | null>(null); const clearTimers = useCallback(() => { if (savingTimer.current) clearTimeout(savingTimer.current); if (savedTimer.current) clearTimeout(savedTimer.current); savingTimer.current = null; savedTimer.current = null; }, []); useEffect(() => { mounted.current = true; return () => { mounted.current = false; clearTimers(); detachedWork.current(); for (const dirty of dirtyWrites.current.values()) dirty.release(); dirtyWrites.current.clear(); }; }, [clearTimers]); const previousOwner = useRef(owner); useEffect(() => { const previous = previousOwner.current; previousOwner.current = owner; if (previous === owner) return; if (previous === undefined && owner !== undefined) { for (const job of jobs.current.values()) { if (job.owner !== undefined) continue; if (!owned(job)) { releaseJob(job); jobs.current.delete(job.target); if (generations.current.get(job.target) === job.id) generations.current.delete(job.target); } } refresh(value => value + 1); reportPendingJobs(); return; } for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); generations.current.clear(); clearTimers(); reportPendingJobs(); for (const dirty of dirtyWrites.current.values()) dirty.release(); dirtyWrites.current.clear(); setView({ target: targetRef.current, status: "idle", error: null }); refresh(value => value + 1); }, [owner, clearTimers]); const storageKey = options.storageKey ?? null; const persistDraft = useCallback((draft: T) => { if (storageKey) saveStructuredDraft(storageKey, { version: 1, draft }); }, [storageKey]); const clearStoredDraft = useCallback(() => { if (!storageKey) return; try { localStorage.removeItem(storageKey); } catch {} }, [storageKey]); const readStoredDraft = useCallback((): T | null => { if (!storageKey) return null; const stored = loadStructuredDraft<{ version?: unknown; draft?: T } | null>(storageKey, null); return stored?.version === 1 && stored.draft !== undefined ? stored.draft : null; }, [storageKey]); const execute = useCallback(async (job: Job, retry = false): Promise => { const stillOwned = () => owned(job); const visible = () => mounted.current && stillOwned() && targetRef.current === job.target; const publish = (status: DurableAutosaveStatus, error: ErrorDescription | null = null) => { if (visible()) setView({ target: job.target, status, error }); }; if (!stillOwned()) { if (jobs.current.get(job.target) === job) jobs.current.delete(job.target); reportPendingJobs(); releaseJob(job); releaseDirty(job.target, job.dirtyVersion); if (mounted.current) refresh(value => value + 1); return { kind: "waiting" }; } if (job.saving) return { kind: "waiting" }; if (visible()) clearTimers(); if (store.getSnapshot().status !== "online") { publish("waiting"); return { kind: "waiting" }; } if (retry) job.resends += 1; job.saving = true; const finishWrite = trackPendingWrite(); if (visible()) savingTimer.current = setTimeout(() => publish("saving"), SAVING_DELAY_MS); refresh(value => value + 1); const remove = () => { releaseJob(job); if (jobs.current.get(job.target) === job) jobs.current.delete(job.target); reportPendingJobs(); releaseDirty(job.target, job.dirtyVersion); if (mounted.current) refresh(value => value + 1); }; const done = (): DurableAutosaveResult => { if (job.storageKey) { const stored = loadStructuredDraft<{ draft?: T } | null>(job.storageKey, null); if (stored && JSON.stringify(stored.draft) === JSON.stringify(job.draft)) { try { localStorage.removeItem(job.storageKey); } catch {} } } if (visible()) { clearTimers(); publish("saved"); savedTimer.current = setTimeout(() => { if (mounted.current && readOwner() === job.owner && generations.current.get(job.target) === job.id && targetRef.current === job.target) setView(current => current.target === job.target && current.status === "saved" ? { ...current, status: "idle" } : current); }, SAVED_LINGER_MS); } remove(); return { kind: "saved" }; }; try { if (job.account && (!mounted.current || (client && hasUnverifiedSession(client))) && !job.account.isCurrentAccount()) { try { await job.account.whenAccountReady(); } catch { // Session discovery has not attempted the write. Keep its draft within // the existing retry budget unless an account transition invalidated it. if (!stillOwned()) remove(); else { if (visible()) clearTimers(); publish("waiting"); } return { kind: "waiting" }; } } if (client && hasUnverifiedSession(client)) { if (visible()) clearTimers(); publish("waiting"); return { kind: "waiting" }; } if (!stillOwned()) { remove(); return { kind: "waiting" }; } if (job.beforeSave) { await job.beforeSave(job.draft); if (!stillOwned()) { remove(); return { kind: "waiting" }; } } await job.save(job.draft); return done(); } catch (initialCause) { let cause = initialCause; if (classifyError(cause) === "conflict" && job.matchesServer) { try { if (await job.matchesServer(cause, job.draft)) return done(); } catch (verificationError) { cause = verificationError; } } if (classifySendFailure(cause) === "pending") { if (visible()) clearTimers(); publish("waiting"); return { kind: "waiting" }; } if (visible()) clearTimers(); publish("error", describeError(cause, { action: job.action })); remove(); return { kind: "failed", error: cause }; } finally { job.saving = false; finishWrite(); if (mounted.current) refresh(value => value + 1); else detachedWake.current?.(); } }, [clearTimers, store, readOwner, client]); const run = useCallback(async (draft: T, overrides: DurableAutosaveJob = {}) => { const current = optionsRef.current; const target = current.targetKey ?? current.sourceId; const admittedOwner = ownerRef.current ?? readOwner(); // A newer known-account edit supersedes an older startup job even if this // save finishes before that job can adopt the account's registry key. if (admittedOwner !== undefined) registry.delete(JSON.stringify([undefined, target])); const job: Job = { token: Symbol(), dirtyVersion: dirtyWrites.current.get(target)?.version, registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, account: client ? bindAccount(client) : null, draft, beforeSave: overrides.beforeSave, save: overrides.save ?? current.save, matchesServer: overrides.matchesServer ?? current.matchesServer, onRetryFailed: overrides.onRetryFailed, storageKey: current.storageKey ?? null, action: current.action ?? "save" }; const previous = jobs.current.get(target); if (previous) releaseJob(previous); let revision = registry.get(job.registryKey); if (!revision) { revision = { latest: job.token, active: new Set() }; registry.set(job.registryKey, revision); } revision.latest = job.token; revision.active.add(job.token); generations.current.set(target, job.id); jobs.current.set(target, job); reportPendingJobs(); refresh(value => value + 1); return execute(job); }, [client, execute, readOwner]); const waitingJobs = [...jobs.current.values()].filter(job => !job.saving).length; const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: jobs.current.size, resend: async (): Promise => { for (const job of [...jobs.current.values()]) { if (jobs.current.get(job.target) !== job) continue; const result = await execute(job, true); if (result.kind === "failed") job.onRetryFailed?.(result.error); } return jobs.current.size ? "pending" : "sent"; }, }); detachedWork.current = () => { if (!jobs.current.size) return; if (auto.stalled) { for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); return; } const source = `${optionsRef.current.sourceId}:detached:${instanceId}`; let timer: ReturnType | null = null; let disposed = false, running = false; let unsubscribe = () => {}; const release = () => { disposed = true; if (!mounted.current) { for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); } if (timer) clearTimeout(timer); unsubscribe(); detachedWake.current = null; store.setPendingWrites(source, 0); }; const wake = async () => { if (disposed || running) return; if (mounted.current) { release(); return; } for (const job of jobs.current.values()) if (!owned(job)) { releaseJob(job); jobs.current.delete(job.target); } if (!jobs.current.size) { release(); return; } store.setPendingWrites(source, jobs.current.size); if (store.getSnapshot().status !== "online") return; running = true; for (const job of [...jobs.current.values()]) { if (job.saving || job.resends >= MAX_ONLINE_RESENDS) continue; const result = await execute(job, true); if (result.kind === "failed") job.onRetryFailed?.(result.error); } running = false; if (disposed) return; const waiting = [...jobs.current.values()].filter(job => !job.saving && job.resends < MAX_ONLINE_RESENDS); if (!waiting.length && ![...jobs.current.values()].some(job => job.saving)) { release(); return; } store.setPendingWrites(source, jobs.current.size); if (waiting.length && store.getSnapshot().status === "online") { timer = setTimeout(() => { timer = null; void wake(); }, resendDelayFor(Math.max(...waiting.map(job => job.resends)))); } }; detachedWake.current = () => { if (!running) void wake(); }; unsubscribe = store.onRecover(() => { if (timer) clearTimeout(timer); for (const job of jobs.current.values()) job.resends = 0; void wake(); }); store.setPendingWrites(source, jobs.current.size); timer = setTimeout(() => { timer = null; void wake(); }, 0); }; const markDirty = useCallback(() => { const dirty = dirtyWrites.current.get(targetRef.current); dirtyWrites.current.set(targetRef.current, { version: Symbol(), release: dirty?.release ?? trackPendingWrite() }); clearTimers(); setView(current => ({ target: targetRef.current, status: jobs.current.has(targetRef.current) ? "waiting" : "idle", error: null })); }, [clearTimers]); const clearDirty = useCallback(() => { releaseDirty(targetRef.current, dirtyWrites.current.get(targetRef.current)?.version); }, []); const reset = useCallback(() => { releaseDirty(targetRef.current, dirtyWrites.current.get(targetRef.current)?.version); const job = jobs.current.get(targetRef.current); if (job) { const revision = registry.get(job.registryKey); if (revision?.latest === job.token) revision.latest = Symbol(); releaseJob(job); } generations.current.delete(targetRef.current); const removed = jobs.current.delete(targetRef.current); clearTimers(); reportPendingJobs(); setView({ target: targetRef.current, status: "idle", error: null }); if (removed) refresh(value => value + 1); }, [clearTimers]); const baseStatus = view.target === targetKey ? view.status : jobs.current.has(targetKey) ? "waiting" : "idle"; const status = baseStatus === "waiting" && auto.stalled ? "stalled" : baseStatus; const error = view.target === targetKey ? view.error : null; return { status, error, label: autosaveLabel(status, error), run, markDirty, clearDirty, reset, persistDraft, readStoredDraft, clearStoredDraft, resendNow: auto.resendNow }; }