From 3a0116248e642be5eaf04c2da7935beb7db7c310 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 11:50:53 +0100 Subject: [PATCH] fix(web): guard autosave against in-flight lost-update race --- web/app/workflows/[id]/page.tsx | 49 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/web/app/workflows/[id]/page.tsx b/web/app/workflows/[id]/page.tsx index 35983e7..24302e6 100644 --- a/web/app/workflows/[id]/page.tsx +++ b/web/app/workflows/[id]/page.tsx @@ -56,6 +56,8 @@ export default function WorkflowBuilder() { const [lastSaved, setLastSaved] = useState(null); const [, setTick] = useState(0); const savedSnapshotRef = useRef(null); + const savingRef = useRef(false); + const wfRef = useRef(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -107,6 +109,10 @@ export default function WorkflowBuilder() { return
Loading…
; } + // 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() { - setWf(w)} onClose={() => setEditWorkflowOpen(false)} /> + { + // 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)} + /> setPickerOpen(false)}