From 0e43b7c0c9ee5b3cf4c1860a93621abdfe505ffe Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 14:49:36 -0700 Subject: [PATCH] [eric] workflows: settle seq forces a post-commit occurrences refetch, the optimistic toggle can no longer strand Coming up on pre-commit truth --- frontend/src/app/pages/Workflows/ScheduleCalendar.tsx | 6 ++++-- .../app/pages/Workflows/app/useCalendarOccurrences.ts | 6 ++++-- frontend/src/shared/state/workflowsSlice.ts | 10 ++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 5407a4d0..af9fe92a 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -78,14 +78,16 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD .join('|'); const fromIso = rangeStart.toISOString(); const toIso = rangeEndExclusive.toISOString(); - const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`; + // Same race as useCalendarOccurrences: the optimistic pending update flips the fingerprint pre-commit; settleSeq forces one post-commit refetch and busts the interceptor's 1s URL cache. + const settleSeq = useAppSelector((s) => s.workflows.settleSeq); + const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}:${settleSeq}`; // The visible window alone decides whether shown events are even plausible. Gating on this (not the full request key) means a schedule edit refetches without blanking the calendar first: we keep the current events until the fresh ones land. Only a view/date change, where old events are for the wrong window, clears them. const calendarWindowKey = `${view}:${fromIso}:${toIso}`; useEffect(() => { // No AbortController: the global fetch interceptor (shared/config) dedupes GETs by URL onto ONE underlying request, so aborting on cleanup (which fires when this effect re-runs as workflows hydrate) rejects the shared request and the re-fired fetch with it, leaving the calendar empty on first load. The `cancelled` guard already stops stale state writes. let cancelled = false; - fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`) + fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}&settle=${settleSeq}`) .then((res) => { if (!res.ok) throw new Error(`calendar failed ${res.status}`); return res.json(); diff --git a/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts b/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts index d25b6f90..99e8ecc7 100644 --- a/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts +++ b/frontend/src/app/pages/Workflows/app/useCalendarOccurrences.ts @@ -22,15 +22,17 @@ export function useCalendarOccurrences(fromIso: string, toIso: string): { [items], ); + // The optimistic pending update flips the fingerprint BEFORE the PATCH commits, so the fingerprint-keyed fetch can read pre-commit truth and then never re-ask (the "toggled the schedule off but Coming up still shows it" bug). settleSeq bumps when the write settles, forcing one authoritative refetch; it also rides the URL so the interceptor's 1s GET cache cannot serve the raced response back. + const settleSeq = useAppSelector((s) => s.workflows.settleSeq); const windowKey = `${fromIso}:${toIso}`; - const requestKey = `${windowKey}:${scheduleKey}`; + const requestKey = `${windowKey}:${scheduleKey}:${settleSeq}`; const [events, setEvents] = useState([]); const [fetchedWindowKey, setFetchedWindowKey] = useState(''); useEffect(() => { // No AbortController: the global fetch interceptor dedupes GETs by URL onto one request, so aborting on re-run (as workflows hydrate) would reject the shared request. The `cancelled` guard stops stale state writes instead. let cancelled = false; - fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`) + fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}&settle=${settleSeq}`) .then((res) => { if (!res.ok) throw new Error(`calendar failed ${res.status}`); return res.json(); diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index b5f77347..5233c4f2 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -208,9 +208,11 @@ interface State { runControlPending: Record; deleted: Workflow[]; deletedLoading: boolean; + /** Bumped when a workflow WRITE settles server-side. The optimistic pending update changes the schedule fingerprint BEFORE the PATCH commits, so an occurrences fetch keyed only on the fingerprint reads pre-commit truth and never re-asks; this seq forces one authoritative refetch per settled write. */ + settleSeq: number; } -const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, noticeToast: null, runControlPending: {}, deleted: [], deletedLoading: false }; +const initialState: State = { items: {}, runs: {}, openCards: {}, loaded: false, loading: false, paused: false, active: [], cloudSmsEnabled: false, allRuns: [], allRunsLoading: false, runningToast: null, noticeToast: null, runControlPending: {}, deleted: [], deletedLoading: false, settleSeq: 0 }; function mergeRunIntoState(state: State, r: WorkflowRun) { const arr = state.runs[r.workflow_id] || []; @@ -601,9 +603,9 @@ const slice = createSlice({ const cur = state.items[id]; if (cur) state.items[id] = { ...cur, ...patch }; }) - .addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) - .addCase(commitDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) - .addCase(discardDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; }) + .addCase(updateWorkflow.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; state.settleSeq += 1; }) + .addCase(commitDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; state.settleSeq += 1; }) + .addCase(discardDraft.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; state.settleSeq += 1; }) .addCase(deleteWorkflow.rejected, (state, action) => { state.noticeToast = typeof action.payload === 'string' ? action.payload : "Couldn't delete this workflow. Try again in a moment."; })