FN-7053: catch up tasks on SSE re-entry

Reconcile task list catch-up behavior when re-entering live Board/List views.

- Move Board/List SSE re-entry refetch ownership from App into useTasks.
- Trigger exactly one catch-up fetch for each sseEnabled false-to-true transition without duplicating initial mount.
- Cover disabled, search-query, and repeated toggle cases in useTasks tests.

Files changed:
 packages/dashboard/app/App.tsx                     |  22 +----
 .../dashboard/app/hooks/__tests__/useTasks.test.ts | 110 +++++++++++++++++----
 packages/dashboard/app/hooks/useTasks.ts           |  12 +++
 3 files changed, 105 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7053

Fusion-Task-Lineage: 7fd7a05e-deb5-4c0d-9585-288089cb9945
This commit is contained in:
gsxdsm
2026-06-26 01:24:47 -07:00
parent c0d53530cf
commit dbd613a529
3 changed files with 107 additions and 41 deletions

View File

@@ -357,10 +357,10 @@ function AppInner() {
}
}, [handleChangeTaskView, taskView, pushNav]);
// Tasks hook with project context and search query
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
// FNXC:DashboardLiveUpdates 2026-06-26-01:08:
// SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry.
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, refreshTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks(
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
@@ -384,22 +384,6 @@ function AppInner() {
*/
const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks();
const previousTaskViewRef = useRef<TaskView>(taskView);
useEffect(() => {
const previousTaskView = previousTaskViewRef.current;
const wasTaskView = previousTaskView === "board" || previousTaskView === "list";
const isTaskView = taskView === "board" || taskView === "list";
// Task SSE is disabled off board/list. Refetch once when returning because
// in-app navigation does not trigger document.visibilitychange.
if (!wasTaskView && isTaskView) {
void refreshTasks();
}
previousTaskViewRef.current = taskView;
}, [taskView, refreshTasks]);
const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const [graphWorkflowSelection, setGraphWorkflowSelection] = useState<GraphWorkflowSelection | null>(null);

View File

@@ -364,7 +364,7 @@ describe("useTasks", () => {
});
describe("view-transition refresh behavior", () => {
it("does not refetch just because sseEnabled flips from false to true, but refreshTasks fetches once and updates state", async () => {
it("refetches exactly once when sseEnabled flips from false to true and updates state", async () => {
const initialTask = createMockTask({ id: "FN-001", title: "Before return" });
const refreshedTask = createMockTask({ id: "FN-002", title: "After return" });
mockFetchTasks
@@ -379,25 +379,54 @@ describe("useTasks", () => {
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-001");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
await act(async () => {
rerender({ sseEnabled: true });
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-002");
});
});
await act(async () => {
await result.current.refreshTasks();
it("does not duplicate the initial fetch when mounting with sseEnabled true", async () => {
const initialTask = createMockTask({ id: "FN-INITIAL" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks({ sseEnabled: true }));
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-INITIAL");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("does not catch up when sseEnabled stays false for the hook lifetime", async () => {
const initialTask = createMockTask({ id: "FN-DISABLED" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result, rerender } = renderHook(
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
{ initialProps: { sseEnabled: false } },
);
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-DISABLED");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
expect(result.current.tasks[0]?.id).toBe("FN-002");
await act(async () => {
rerender({ sseEnabled: false });
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
expect(MockEventSource.instances).toHaveLength(0);
});
it("refreshTasks preserves active searchQuery when returning to task views", async () => {
vi.useFakeTimers();
const filteredTask = createMockTask({ id: "FN-SEARCH", title: "match" });
mockFetchTasks
.mockResolvedValueOnce([])
@@ -406,18 +435,13 @@ describe("useTasks", () => {
const { result, rerender } = renderHook(
({ searchQuery, sseEnabled }: { searchQuery?: string; sseEnabled: boolean }) =>
useTasks({ searchQuery, sseEnabled }),
{ initialProps: { searchQuery: undefined, sseEnabled: false } },
{ initialProps: { searchQuery: "match", sseEnabled: false } },
);
await act(async () => {
await flushPromises();
});
await act(async () => {
rerender({ searchQuery: "match", sseEnabled: false });
vi.advanceTimersByTime(300);
await flushPromises();
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, "match", false);
mockFetchTasks.mockClear();
@@ -425,13 +449,13 @@ describe("useTasks", () => {
rerender({ searchQuery: "match", sseEnabled: true });
});
await act(async () => {
await result.current.refreshTasks();
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, "match", false);
expect(result.current.tasks[0]?.id).toBe("FN-SEARCH");
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-SEARCH");
});
});
it("does not refetch when toggling between already-live task views", async () => {
@@ -453,6 +477,52 @@ describe("useTasks", () => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("runs one catch-up per false-to-true toggle without stacked EventSource instances", async () => {
const initialTask = createMockTask({ id: "FN-RAPID-0" });
const firstReturnTask = createMockTask({ id: "FN-RAPID-1" });
const secondReturnTask = createMockTask({ id: "FN-RAPID-2" });
mockFetchTasks
.mockResolvedValueOnce([initialTask])
.mockResolvedValueOnce([firstReturnTask])
.mockResolvedValueOnce([secondReturnTask]);
const { result, rerender } = renderHook(
({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }),
{ initialProps: { sseEnabled: false } },
);
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-RAPID-0");
});
await act(async () => {
rerender({ sseEnabled: true });
});
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-RAPID-1");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0]?.readyState).toBe(1);
await act(async () => {
rerender({ sseEnabled: false });
});
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0]?.readyState).toBe(MockEventSource.CLOSED);
await act(async () => {
rerender({ sseEnabled: true });
});
await waitFor(() => {
expect(result.current.tasks[0]?.id).toBe("FN-RAPID-2");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(3);
expect(MockEventSource.instances).toHaveLength(2);
expect(MockEventSource.instances[1]?.readyState).toBe(1);
});
});
describe("SSE event: task:created", () => {

View File

@@ -133,6 +133,7 @@ export function useTasks(options?: UseTasksOptions) {
const droppedStaleEventsRef = useRef(0);
const searchQueryRef = useRef(searchQuery);
const refreshTasksRef = useRef<typeof refreshTasks>(null!);
const prevSseEnabledRef = useRef(sseEnabled);
// Tracks when task data was last confirmed fresh by the server.
// Used to prevent false positives in stuck detection when tab has been in background.
const lastFetchTimeMs = useRef<number | undefined>(undefined);
@@ -193,6 +194,17 @@ export function useTasks(options?: UseTasksOptions) {
}, [projectId]);
refreshTasksRef.current = refreshTasks;
// FNXC:DashboardLiveUpdates 2026-06-26-01:08:
// Task SSE is disabled outside Board/List, so task:created/moved/updated/deleted/merged events emitted off-view are never delivered. On the sseEnabled false→true re-entry, perform exactly one hook-owned catch-up refetch while excluding initial mount and the disabled state; visibilitychange and onReconnect keep their separate refetch ownership.
useEffect(() => {
const previous = prevSseEnabledRef.current;
prevSseEnabledRef.current = sseEnabled;
if (previous === false && sseEnabled === true) {
void refreshTasksRef.current();
}
}, [sseEnabled]);
/** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */
const loadArchivedTasks = useCallback(async () => {
if (includeArchivedRef.current) return;