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>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
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();
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user