Files
fusion/packages/dashboard/app/hooks/useBoardWorkflows.ts
gsxdsm 6721bdc652 U12 part 7: the List view never self-healed a card's workflow — extract Board's FN-7591 refetch and wire it up (#2530)
## U12 part 7 — the List view never self-healed a card's workflow

**Stacks on #2528.** Merge that first.

Paying off something I owed on #2525: greptile pointed out that a task
whose `taskWorkflowIds` entry is absent — or present but resolving to a
workflow that does not declare the task's stored column — gets no
per-workflow move metadata, so its menu falls back to the neighbour
approximation and **stays there until some unrelated refresh happens**.

Board has forced one board-workflows refetch for exactly this since
FN-7591. List had none. So the degraded state persisted longest
precisely where it is most likely: a **just-created card**, which is
when a workflow was actually chosen.

I said there that porting the self-heal deserved its own change rather
than riding along in a move-menu fix. This is it.

### Two commits, deliberately separable

**1. Extraction — move only.** Board's ~55 lines (refs, suspect-mapping
predicate, signature guard, deferred macrotask) become
`useUnmappedWorkflowRefetch`. Copying them into ListView would have
created a second copy of subtle race-avoidance logic to keep in sync.

Evidence it is a move: with comments and the new wrapper signature
stripped, the hook's **41 body lines** and the **42 removed from Board**
differ by exactly one line — the `}` that closed Board's enclosing
scope. Nothing added, removed or reordered. The original FNXC notes
travel with the code, since they are the reason each line exists.
Board's suite is green with no expectation edits.

**2. Wiring — behaviour change.** ListView calls the hook.

### Revert-proof

Remove the hook call from ListView and the new case fails:
`fetchBoardWorkflows` is never called a second time, so the mapping
never resolves. A companion case pins the other half — a fully-mapped
board must **not** refetch, so the signature guard cannot turn a healthy
list into a loop. It measures calls made *after* the initial load
settles, because mount fetch and switcher-open legitimately call the
fetcher and counting from zero would measure those instead.

### Two existing tests needed fixture corrections — neither a regression

Both because the self-heal now fires **correctly** where the fixture did
not expect a fetch:

- `refreshes workflow columns when workflow metadata SSE arrives`
chained two `mockResolvedValueOnce` payloads. The file-level cache seed
maps no tasks, so first paint saw FN-001 as unmapped and the repair
fetch ate the payload the test asserts on. Seeded that test's own
first-paint cache, and added a trailing default — the SSE swap
(`backlog` → `ready`) leaves FN-001 in a column its workflow no longer
declares, so a repair fetch there is right, and without a fallback it
resolved `undefined` and wiped the payload.

Worth stating plainly: both fixtures had quietly depended on List
*never* self-healing. That dependency is what the change removes.

### Verification

`pnpm test:gate` (309 + 10 + 71), `pnpm lint`, `pnpm verify:fast` (18
steps), dashboard typecheck green. ListView + Board suites: **320
passed, 0 failed, 0 skipped**.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* List and Board views now self-recover when task-to-workflow mappings
are missing or incorrect, avoiding degraded workflow UI until a later
refresh.
* Workflow recovery retries are more robust and coordinated to handle
delayed/failed refreshes.
* Recovery behavior correctly stops/reset when switching projects or
unmounting.

* **Tests**
* Added comprehensive ListView coverage for unmapped-workflow self-heal,
including retry timing, StrictMode effect replay, SSE refresh
interactions, and mapped-vs-unmapped scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 09:04:58 -07:00

297 lines
15 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
fetchBoardWorkflows as defaultFetchBoardWorkflows,
type BoardWorkflowDefinition,
type BoardWorkflowsPayload,
} from "../api";
import { setProjectBoardSelectedWorkflow as defaultPersistBoardWorkflowSelection } from "../api/workflows";
import { subscribeSse as defaultSubscribeSse } from "../sse-bus";
import {
clearBoardWorkflowsCache as defaultClearBoardWorkflowsCache,
readBoardWorkflowsCache as defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
} from "../utils/boardWorkflowsCache";
import {
ALL_WORKFLOWS_BOARD_VIEW_ID,
readBoardWorkflowViewSelection,
removeBoardWorkflowSelection,
writeBoardWorkflowSelection,
} from "../utils/boardWorkflowSelection";
import { notifyWorkflowSettingValuesUpdatedFromSse } from "../utils/workflowSettingValuesEvents";
/*
FNXC:Workflows 2026-06-22-17:00:
Single source of truth for board-workflow fetch/cache/SSE/selection, shared verbatim by Board.tsx and the Planning header slot (PlanningWorkflowSwitcherSlot.tsx). Both surfaces must show the SAME workflow dropdown driven by the SAME data path: refetch on mount, on tab visibility/focus, and on `workflow:created|updated|deleted` SSE; every fetch is guarded by a monotonic sequence ref that drops out-of-order responses; successful payloads persist to the per-project session cache. Selection (`selectedWorkflowId`) hydrates from project-scoped durable storage, user changes write immediately, and stale stored ids are repaired only after the current payload proves the workflow no longer exists.
FNXC:Workflows 2026-06-29-14:45:
Transient board-workflows fetch failures are not authoritative workflow-mode disable signals. Preserve the last payload and durable workflow selection on API/focus/refresh blips so operators return to their selected lane unless the server explicitly returns workflow mode off, an empty list, or a single unswitchable workflow.
Per-consumer subscription semantics are preserved: each call to this hook installs its OWN visibilitychange/focus listeners and its OWN SSE subscription, so two consumers (Board + Planning slot) each subscribe and unsubscribe independently — the hook does not dedupe across consumers. Dependencies (fetch, subscribeSse, cache helpers) are injectable to keep the hook DI-friendly and free of App-level singletons.
*/
export interface UseBoardWorkflowsParams {
projectId?: string;
fetchBoardWorkflows?: typeof defaultFetchBoardWorkflows;
subscribeSse?: typeof defaultSubscribeSse;
readBoardWorkflowsCache?: typeof defaultReadBoardWorkflowsCache;
writeBoardWorkflowsCache?: typeof defaultWriteBoardWorkflowsCache;
clearBoardWorkflowsCache?: typeof defaultClearBoardWorkflowsCache;
/**
* FNXC:OriginWorkflowSelection 2026-07-26-19:40:
* Server-side mirror of the operator's lane, so `fn task create` / `fn_task_create` /
* refinement can resolve the "Selected workflow" option of `taskCreateWorkflowId` /
* `refinementTaskWorkflowId`. Injectable like the other deps to keep the hook testable.
*/
persistBoardWorkflowSelection?: typeof defaultPersistBoardWorkflowSelection;
}
export interface UseBoardWorkflowsResult {
/** Raw payload for the current project, or null when unloaded / project mismatch. */
boardWorkflows: BoardWorkflowsPayload | null;
/** True when the flag is on AND at least one workflow is defined. */
workflowMode: boolean;
/** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */
workflowOptions: BoardWorkflowDefinition[];
/** Currently selected real workflow (resolved from selection / default / first), or null. */
selectedWorkflow: BoardWorkflowDefinition | null;
selectedWorkflowId: string | null;
/** True when the dashboard-only aggregate workflow view is selected. */
isAllWorkflowsSelected: boolean;
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
/** Force a fresh fetch (used on switcher open, and when the board detects a rendered
* task missing from `taskWorkflowIds`, since task→workflow assignment emits no workflow SSE).
* Resolves when the fetch has SETTLED — it never rejects, since a failed fetch is
* non-authoritative — so a caller that must not overlap attempts can await it. */
refreshBoardWorkflows: (options?: { forceFresh?: boolean }) => Promise<void>;
/**
* Raw state setter, exposed so Board can apply optimistic task→workflow assignment.
* Planning does not use this.
*/
setBoardWorkflowsState: Dispatch<SetStateAction<{ projectId?: string; payload: BoardWorkflowsPayload } | null>>;
}
export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWorkflowsResult {
const {
projectId,
fetchBoardWorkflows = defaultFetchBoardWorkflows,
subscribeSse = defaultSubscribeSse,
readBoardWorkflowsCache = defaultReadBoardWorkflowsCache,
writeBoardWorkflowsCache = defaultWriteBoardWorkflowsCache,
clearBoardWorkflowsCache = defaultClearBoardWorkflowsCache,
persistBoardWorkflowSelection = defaultPersistBoardWorkflowSelection,
} = params;
const [boardWorkflowsState, setBoardWorkflowsState] = useState<{ projectId?: string; payload: BoardWorkflowsPayload } | null>(() => {
const cached = readBoardWorkflowsCache(projectId);
return cached ? { projectId, payload: cached } : null;
});
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowViewSelection(projectId));
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
/** Lane mirror queued by the user-driven setter; `undefined` = nothing to flush. */
const pendingLaneMirrorRef = useRef<string | null | undefined>(undefined);
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => {
setSelectedWorkflowIdState((previousSelection) => {
const resolvedSelection = typeof nextSelection === "function"
? nextSelection(previousSelection)
: nextSelection;
storedSelectionRef.current = resolvedSelection;
if (resolvedSelection) {
writeBoardWorkflowSelection(projectId, resolvedSelection);
} else {
removeBoardWorkflowSelection(projectId);
}
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Queue the server-side lane mirror only on this USER-driven setter, not on the
stale-selection repair effect below — the mirror should track what the operator chose,
not the hook's own bookkeeping. The all-workflows sentinel is a Board-only view, never
a real workflow id, so it queues a CLEAR rather than being persisted as an id.
Queued, not issued here: React may invoke a state updater more than once for a single
change, and firing a request from inside one produced duplicate writes and a render
loop. The flush effect below owns the actual call.
*/
pendingLaneMirrorRef.current = resolvedSelection && resolvedSelection !== ALL_WORKFLOWS_BOARD_VIEW_ID
? resolvedSelection
: null;
return resolvedSelection;
});
}, [projectId]);
/*
FNXC:OriginWorkflowSelection 2026-07-26-19:40:
Flush the queued lane mirror after commit. `undefined` means "nothing queued" — distinct
from `null`, which is a real instruction to CLEAR the mirror — so the effect is a no-op on
mount and on every render the operator did not drive. Unkeyed on purpose: it must run after
whichever commit the setter's update landed in, and the ref latch already bounds it to one
request per operator action.
The promise is unawaited and its rejection swallowed: localStorage already holds the
authoritative selection, so a failed mirror must never revert or block the lane switch.
*/
useEffect(() => {
const pending = pendingLaneMirrorRef.current;
if (pending === undefined) return;
pendingLaneMirrorRef.current = undefined;
void persistBoardWorkflowSelection(pending, projectId).catch(() => {});
});
// Stale-response guard: a monotonic sequence ref drops out-of-order responses.
const boardWorkflowsFetchSeqRef = useRef(0);
// Re-hydrate from the per-project cache on project change.
useEffect(() => {
const cached = readBoardWorkflowsCache(projectId);
const storedSelection = readBoardWorkflowViewSelection(projectId);
storedSelectionRef.current = storedSelection;
setSelectedWorkflowIdState(storedSelection);
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
}, [projectId, readBoardWorkflowsCache]);
/*
FNXC:WorkflowBoard 2026-07-29-00:00 (PR #2530 review — greptile):
RETURNS its settle promise now (additive — existing callers ignore it). The
unmapped-workflow repair needs to know when a forced refresh has SETTLED: on a slow
request it was starting its second attempt on a fixed 250ms timer while the first was
still in flight, so both attempts could be spent before either answer arrived. The
promise never rejects — a failed fetch stays non-authoritative — so awaiting it is
safe for every caller.
*/
const refreshBoardWorkflows = useCallback((options?: { forceFresh?: boolean }): Promise<void> => {
const seq = ++boardWorkflowsFetchSeqRef.current;
if (options?.forceFresh) {
clearBoardWorkflowsCache(projectId);
}
const fetchPromise = options === undefined
? fetchBoardWorkflows(projectId)
: fetchBoardWorkflows(projectId, options);
return fetchPromise
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflowsState({ projectId, payload });
writeBoardWorkflowsCache(projectId, payload);
}
})
.catch(() => {
// Fetch failures are non-authoritative: keep the current/cache-hydrated payload so the cleanup effect does not erase durable selection.
});
}, [projectId, fetchBoardWorkflows, writeBoardWorkflowsCache, clearBoardWorkflowsCache]);
useEffect(() => {
refreshBoardWorkflows();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") refreshBoardWorkflows();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const forceRefreshBoardWorkflows = () => refreshBoardWorkflows({ forceFresh: true });
/*
FNXC:BoardWorkflows 2026-07-26-15:14:
The visibilitychange/focus listeners above cover a backgrounded tab, but not an error-driven SSE
reconnect while the tab stays visible, during which workflow mutations are dropped. Declare the
resync on the subscription itself so recovery does not depend on which of the two paths fired.
*/
const unsubscribe = subscribeSse(`/api/events${query}`, {
onReconnect: forceRefreshBoardWorkflows,
events: {
"workflow:created": forceRefreshBoardWorkflows,
"workflow:updated": forceRefreshBoardWorkflows,
"workflow:deleted": forceRefreshBoardWorkflows,
"workflow:setting-values-updated": (event: MessageEvent) => {
try {
notifyWorkflowSettingValuesUpdatedFromSse(JSON.parse(event.data) as Record<string, unknown>);
} catch {
// Malformed SSE payloads are non-authoritative and cannot invalidate a card.
}
},
},
});
return () => {
// Advance the seq so any in-flight response is dropped on cleanup.
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId, refreshBoardWorkflows, subscribeSse]);
/*
FNXC:WorkflowColumns 2026-07-28-00:00 (U12 — R9):
Workflow mode is now "this project resolved at least one lane", nothing else.
The former `flagEnabled === true` conjunct is DELETED: the server hardcodes that
field to `true` (`buildBoardWorkflowsPayload`), so it could only ever narrow the
result to itself. Reading it kept a retired kill switch alive on the client, one
stale payload away from silently reverting every consumer to the legacy board.
*/
const workflowMode = Boolean(boardWorkflows?.workflows.length);
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const isAllWorkflowsSelected = selectedWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID;
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!boardWorkflows) {
return;
}
if (!workflowMode) {
if (storedSelectionRef.current !== null) {
removeBoardWorkflowSelection(projectId);
storedSelectionRef.current = null;
}
setSelectedWorkflowIdState(null);
return;
}
if (workflowOptions.length < 2) {
if (storedSelectionRef.current !== null) {
removeBoardWorkflowSelection(projectId);
storedSelectionRef.current = null;
}
setSelectedWorkflowIdState(selectedWorkflow?.id ?? null);
return;
}
if (isAllWorkflowsSelected) {
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
const shouldRepairStoredSelection = storedSelectionRef.current !== null;
setSelectedWorkflowIdState(selectedWorkflow.id);
if (shouldRepairStoredSelection) {
writeBoardWorkflowSelection(projectId, selectedWorkflow.id);
storedSelectionRef.current = selectedWorkflow.id;
}
}
}, [boardWorkflows, isAllWorkflowsSelected, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
return {
boardWorkflows,
workflowMode,
workflowOptions,
selectedWorkflow,
selectedWorkflowId,
isAllWorkflowsSelected,
setSelectedWorkflowId,
refreshBoardWorkflows,
setBoardWorkflowsState,
};
}