fix(web): guard autosave against in-flight lost-update race
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user