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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user