Organize/hooks/use-autosave.ts

65 lines
2.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
// "A few seconds" per the group session's live-editing requirement --
// short enough to feel live, long enough not to fire a save on every
// keystroke. Deliberately well under board-context's 30s cross-device
// poll interval, so a local edit always settles before the poll could
// otherwise consider overwriting it.
export const AUTOSAVE_DELAY_MS = 2500;
/**
* Debounced autosave for a single field's value. Call `onSave` a beat
* after `value` stops changing, and again immediately via `flush()` (used
* by a manual "Save" button and by flush-on-close) -- both paths funnel
* through the same `runSave`, so there's no separate "save now" logic to
* keep in sync with the debounced one.
*
* `onSave` must be one of the board-context mutators (e.g. `saveNote`,
* `editTodo`), never a raw server action directly -- those mutators already
* mark the write as in-flight (see `track()` in board-context.tsx) so the
* app's cross-device poll doesn't clobber it mid-save.
*/
export function useAutosave<T>(
value: T,
onSave: (value: T) => Promise<boolean | void>,
delayMs = AUTOSAVE_DELAY_MS
): { flush: () => Promise<void>; isDirty: boolean; isSaving: boolean } {
const [savedValue, setSavedValue] = useState(value);
const [isSaving, setIsSaving] = useState(false);
const onSaveRef = useRef(onSave);
const savingPromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
onSaveRef.current = onSave;
}, [onSave]);
const runSave = useCallback((v: T) => {
setIsSaving(true);
const promise = (async () => {
await onSaveRef.current(v);
setSavedValue(v);
setIsSaving(false);
})();
savingPromiseRef.current = promise;
return promise;
}, []);
useEffect(() => {
if (value === savedValue) return;
const timer = setTimeout(() => {
void runSave(value);
}, delayMs);
return () => clearTimeout(timer);
}, [value, savedValue, delayMs, runSave]);
const flush = useCallback(async () => {
if (value !== savedValue) {
await runSave(value);
} else if (savingPromiseRef.current) {
await savingPromiseRef.current;
}
}, [value, savedValue, runSave]);
return { flush, isDirty: value !== savedValue, isSaving };
}