[eric] workflows: settle seq forces a post-commit occurrences refetch, the optimistic toggle can no longer strand Coming up on pre-commit truth

This commit is contained in:
ciregenz
2026-08-06 14:49:36 -07:00
parent 712fd4e21d
commit 0e43b7c0c9
3 changed files with 14 additions and 8 deletions
@@ -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();
@@ -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<CalendarOccurrence[]>([]);
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();
+6 -4
View File
@@ -208,9 +208,11 @@ interface State {
runControlPending: Record<string, WorkflowRunControlAction>;
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.";
})