fix(web): guard autosave against in-flight lost-update race

This commit is contained in:
2026-07-21 11:50:53 +01:00
parent 6af0a88841
commit 3a0116248e
+43 -6
View File
@@ -56,6 +56,8 @@ export default function WorkflowBuilder() {
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [, setTick] = useState(0);
const savedSnapshotRef = useRef<string | null>(null);
const savingRef = useRef(false);
const wfRef = useRef<Workflow | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
@@ -107,6 +109,10 @@ export default function WorkflowBuilder() {
return <div className="p-8 text-text-secondary">Loading</div>;
}
// Keep a ref to the latest workflow so an in-flight save can tell whether
// the user edited again while the request was on the wire.
wfRef.current = wf;
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
@@ -115,23 +121,44 @@ export default function WorkflowBuilder() {
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
const save = async () => {
// Never run two saves concurrently: a request in flight would race the
// next one. The finally block re-triggers if edits landed meanwhile.
if (savingRef.current) return;
const current = wfRef.current;
if (!current) return;
const snapshot = JSON.stringify(current);
if (snapshot === savedSnapshotRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
const updated = await api.updateWorkflow(id, wf);
const updated = await api.updateWorkflow(id, current);
if (!updated || !Array.isArray(updated.steps)) {
setError("Save failed: server returned an unexpected response.");
return;
}
// Record the snapshot BEFORE setWf so the autosave effect sees the
// incoming state as already-saved and doesn't re-trigger.
savedSnapshotRef.current = JSON.stringify(updated);
setWf(updated);
if (JSON.stringify(wfRef.current) === snapshot) {
// Nothing changed while the request was in flight: adopt the
// server echo as the new saved baseline.
savedSnapshotRef.current = JSON.stringify(updated);
setWf(updated);
} else {
// The user edited again mid-flight. Keep their newer state and
// mark only the SENT snapshot as saved, so the effect re-fires
// and persists the remaining changes.
savedSnapshotRef.current = snapshot;
}
setLastSaved(new Date());
} catch (e) {
setError((e as Error).message);
} finally {
savingRef.current = false;
setSaving(false);
// If edits arrived during the save (or a concurrent save was
// skipped), persist them on the next tick.
if (wfRef.current && JSON.stringify(wfRef.current) !== savedSnapshotRef.current) {
setTimeout(() => save(), 0);
}
}
};
@@ -604,7 +631,17 @@ export default function WorkflowBuilder() {
</aside>
</div>
<EditWorkflowModal open={editWorkflowOpen} workflow={wf} onSaved={(w) => setWf(w)} onClose={() => setEditWorkflowOpen(false)} />
<EditWorkflowModal
open={editWorkflowOpen}
workflow={wf}
onSaved={(w) => {
// The modal already persisted w; sync the snapshot so
// autosave doesn't fire a redundant follow-up save.
savedSnapshotRef.current = JSON.stringify(w);
setWf(w);
}}
onClose={() => setEditWorkflowOpen(false)}
/>
<StepPickerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}