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>
65 lines
2.6 KiB
TypeScript
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();
|
|
},
|
|
[],
|
|
);
|
|
};
|