Files
fofos-admin/web/useAutosave.ts
T
pastilhasandClaude Opus 5 b5ca67e68e every control writes itself
The Save button is gone, and so is the SaveTarget context that announced it.
Every screen here is a preview of the thing being edited, so a change is already
visible before it is written — a button then asks somebody to confirm what they
can see.

useAutosave debounces a burst into one request. It takes the unsaved state as a
string rather than a flag, so a failed save is not retried on the next render,
and it flushes on unmount so a section change loses nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:34:34 +01:00

65 lines
2.6 KiB
TypeScript

import { useEffect, useRef } from 'react';
/**
* A change writes itself.
*
* ── Why there is no Save button ──
*
* There was one, in the panel's bar, announced by whichever section was open — see the git history for
* `SaveTarget`. Every screen here is a preview of the thing being edited, so the change is already
* visible before it is written; a button then asks somebody to confirm what they can see. What it
* really did was make "did that take?" a question, and answer it only after a press.
*
* ── The debounce is the whole mechanism ──
*
* A select fires per choice and a text field per keystroke. Waiting for a pause turns a burst into one
* request without anybody deciding when the burst ended.
*
* ── `pending` is a STRING, not a flag ──
*
* It is the unsaved state, serialised — whatever the panel would send. That is what makes this safe to
* retry-free: an attempt is remembered by its value, so a save that FAILS does not immediately run
* again on the next render. With a boolean it would: `busy` goes false, `dirty` is still true, and the
* effect starts another timer — a failing request every second for as long as the panel is open.
*
* Null means there is nothing to write, which is different from an empty string.
*/
const DELAY = 600;
export const useAutosave = (pending: string | null, busy: boolean, save: () => void | Promise<void>): void => {
// Held in refs so an ordinary function rebuilt every render does not restart the timer.
const saveRef = useRef(save);
saveRef.current = save;
const pendingRef = useRef(pending);
pendingRef.current = pending;
/** The last value handed to `save`. Compared, so one value is attempted once. */
const tried = useRef<string | null>(null);
useEffect(() => {
if (pending === null || busy || tried.current === pending) return;
const timer = setTimeout(() => {
tried.current = pending;
void saveRef.current();
}, DELAY);
return () => clearTimeout(timer);
}, [pending, busy]);
/*
Leaving with something unwritten.
The panel unmounts when the section changes, and a debounce that has not fired yet would go with
it — so a choice made and then navigated away from within half a second would be lost, silently and
only sometimes. The request outlives the component; the state updates it makes are no-ops, which is
fine because there is nothing left to show them.
*/
useEffect(
() => () => {
if (pendingRef.current !== null && pendingRef.current !== tried.current) void saveRef.current();
},
[],
);
};