From dbd613a52982e02e6fae3656121f7cd00971cc11 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 01:24:47 -0700 Subject: [PATCH] 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 --- packages/dashboard/app/App.tsx | 22 +--- .../app/hooks/__tests__/useTasks.test.ts | 114 ++++++++++++++---- packages/dashboard/app/hooks/useTasks.ts | 12 ++ 3 files changed, 107 insertions(+), 41 deletions(-) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 18f87389ce..bacff9b306 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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); - - 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(null); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 37fe7d6624..4f9210a92e 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -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", () => { diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 1aadcf2d16..fa05b62820 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -133,6 +133,7 @@ export function useTasks(options?: UseTasksOptions) { const droppedStaleEventsRef = useRef(0); const searchQueryRef = useRef(searchQuery); const refreshTasksRef = useRef(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(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;