From ad2cb6958ec1dd1390b8597e468e39bde7682769 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 4 Aug 2026 01:30:33 -0700 Subject: [PATCH] FN-8778: reconcile task state on board re-entry Keep Board and List task data current after users return from views without SSE. - Refresh task data on every genuine false-to-true task-view transition - Coalesce concurrent project switches with re-entry refreshes - Cover server-state reconciliation, empty snapshots, and late responses Files changed: .../dashboard/app/hooks/__tests__/useTasks.test.ts | 87 ++++++++++------------ packages/dashboard/app/hooks/useTasks.ts | 45 ++++------- 2 files changed, 55 insertions(+), 77 deletions(-) Fusion-Task-Id: FN-8778 Fusion-Task-Lineage: 8123b24a-9228-46cc-a382-19c91aa72e73 Co-authored-by: Fusion (runfusion.ai) --- .../app/hooks/__tests__/useTasks.test.ts | 87 +++++++++---------- packages/dashboard/app/hooks/useTasks.ts | 43 +++------ 2 files changed, 54 insertions(+), 76 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 1d37ee7d28..89b8d83226 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -389,9 +389,12 @@ describe("useTasks", () => { }); describe("view-transition refresh behavior", () => { - it("skips the false-to-true catch-up when the in-memory snapshot is fresh", async () => { + it("reconciles a fresh false-to-true return with changed server state", async () => { const initialTask = createMockTask({ id: "FN-001", title: "Before return" }); - mockFetchTasks.mockResolvedValueOnce([initialTask]); + const returnedTask = createMockTask({ id: "FN-001", title: "After return", column: "done" }); + mockFetchTasks + .mockResolvedValueOnce([initialTask]) + .mockResolvedValueOnce([returnedTask]); const { result, rerender } = renderHook( ({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }), @@ -399,30 +402,28 @@ describe("useTasks", () => { ); await waitFor(() => { - expect(result.current.tasks[0]?.id).toBe("FN-001"); + expect(result.current.tasks[0]?.title).toBe("Before return"); }); expect(mockFetchTasks).toHaveBeenCalledTimes(1); - mockFetchTasks.mockClear(); await act(async () => { rerender({ sseEnabled: true }); - await flushPromises(); }); - expect(mockFetchTasks).not.toHaveBeenCalled(); - expect(result.current.tasks[0]?.id).toBe("FN-001"); + await waitFor(() => { + expect(mockFetchTasks).toHaveBeenCalledTimes(2); + expect(result.current.tasks[0]?.title).toBe("After return"); + }); expect(MockEventSource.instances).toHaveLength(1); }); - it("skips only same-project fresh returns and restores the project-scoped SSE subscription", async () => { - const projectTask = createMockTask({ id: "FN-PROJ-1", title: "Project one" }); - mockFetchTasks.mockResolvedValueOnce([projectTask]); - mockReadCache.mockImplementation((key) => { - if (key === `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-2`) { - return [createMockTask({ id: "FN-PROJ-2", title: "Project two cache" })]; - } - return null; - }); + it("coalesces a project switch and task-view return into one new-project fetch", async () => { + let resolveOldProject: ((tasks: Task[]) => void) | undefined; + let resolveNewProject: ((tasks: Task[]) => void) | undefined; + mockFetchTasks.mockImplementation((_limit, _offset, projectId) => new Promise((resolve) => { + if (projectId === "proj-1") resolveOldProject = resolve; + if (projectId === "proj-2") resolveNewProject = resolve; + })); const { result, rerender } = renderHook( ({ projectId, sseEnabled }: { projectId: string; sseEnabled: boolean }) => @@ -430,36 +431,27 @@ describe("useTasks", () => { { initialProps: { projectId: "proj-1", sseEnabled: false } }, ); - await waitFor(() => { - expect(result.current.tasks[0]?.id).toBe("FN-PROJ-1"); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(1)); + await act(async () => { + rerender({ projectId: "proj-2", sseEnabled: true }); }); - expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, "proj-1", undefined, false); - mockFetchTasks.mockClear(); + + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(2)); + expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, "proj-2", undefined, false); + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]?.url).toContain("/api/events?projectId=proj-2"); await act(async () => { - rerender({ projectId: "proj-1", sseEnabled: true }); + resolveNewProject?.([createMockTask({ id: "FN-PROJ-2-LIVE" })]); await flushPromises(); }); + expect(result.current.tasks[0]?.id).toBe("FN-PROJ-2-LIVE"); - expect(mockFetchTasks).not.toHaveBeenCalled(); - expect(result.current.tasks[0]?.id).toBe("FN-PROJ-1"); - expect(MockEventSource.instances.at(-1)?.url).toContain("/api/events?projectId=proj-1"); - - mockFetchTasks.mockResolvedValueOnce([createMockTask({ id: "FN-PROJ-2-LIVE" })]); await act(async () => { - rerender({ projectId: "proj-2", sseEnabled: false }); - }); - - expect(mockReadCache).toHaveBeenCalledWith( - `${swrCache.SWR_CACHE_KEYS.TASKS_PREFIX}proj-2`, - { maxAgeMs: swrCache.SWR_TASKS_MAX_AGE_MS }, - ); - await waitFor(() => { - expect(mockFetchTasks).toHaveBeenCalledWith(undefined, undefined, "proj-2", undefined, false); - }); - await waitFor(() => { - expect(result.current.tasks[0]?.id).toBe("FN-PROJ-2-LIVE"); + resolveOldProject?.([createMockTask({ id: "FN-PROJ-1-LATE" })]); + await flushPromises(); }); + expect(result.current.tasks[0]?.id).toBe("FN-PROJ-2-LIVE"); }); it("performs one false-to-true catch-up when the confirmed snapshot is stale", async () => { @@ -495,8 +487,10 @@ describe("useTasks", () => { vi.useRealTimers(); }); - it("treats a fresh empty server snapshot as confirmed data on task-view return", async () => { - mockFetchTasks.mockResolvedValueOnce([]); + it("reconciles an empty server snapshot on task-view return", async () => { + mockFetchTasks + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); const { result, rerender } = renderHook( ({ sseEnabled }: { sseEnabled: boolean }) => useTasks({ sseEnabled }), @@ -507,14 +501,12 @@ describe("useTasks", () => { expect(result.current.lastFetchTimeMs).toEqual(expect.any(Number)); }); expect(result.current.tasks).toEqual([]); - mockFetchTasks.mockClear(); - await act(async () => { rerender({ sseEnabled: true }); await flushPromises(); }); - expect(mockFetchTasks).not.toHaveBeenCalled(); + expect(mockFetchTasks).toHaveBeenCalledTimes(2); expect(result.current.tasks).toEqual([]); }); @@ -674,11 +666,13 @@ describe("useTasks", () => { expect(mockFetchTasks).toHaveBeenCalledTimes(1); }); - it("keeps SSE reconnect resync active after a fresh return skips catch-up", async () => { + it("keeps SSE reconnect resync active after a task-view catch-up", async () => { const initialTask = createMockTask({ id: "FN-INITIAL" }); + const returnedTask = createMockTask({ id: "FN-RETURNED" }); const reconnectedTask = createMockTask({ id: "FN-RECONNECTED" }); mockFetchTasks .mockResolvedValueOnce([initialTask]) + .mockResolvedValueOnce([returnedTask]) .mockResolvedValueOnce([reconnectedTask]); const { result, rerender, unmount } = renderHook( @@ -689,14 +683,13 @@ describe("useTasks", () => { await waitFor(() => { expect(result.current.tasks[0]?.id).toBe("FN-INITIAL"); }); - mockFetchTasks.mockClear(); await act(async () => { rerender({ sseEnabled: true }); await flushPromises(); }); - expect(mockFetchTasks).not.toHaveBeenCalled(); + expect(mockFetchTasks).toHaveBeenCalledTimes(2); expect(MockEventSource.instances).toHaveLength(1); // The resync fires when the REBUILT stream opens, not on the error (see the FNXC note above). @@ -719,7 +712,7 @@ describe("useTasks", () => { await flushPromises(); }); - expect(mockFetchTasks).toHaveBeenCalledTimes(1); + expect(mockFetchTasks).toHaveBeenCalledTimes(3); expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, undefined, false); await waitFor(() => { expect(result.current.tasks[0]?.id).toBe("FN-RECONNECTED"); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 7b1c0664a0..6af711f8ca 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -12,15 +12,6 @@ import { isLikelyTabSuspensionError } from "./visibilitySuspension"; import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles"; const loggedTaskCacheHitProjects = new Set(); -/* -FNXC:MobileTabDiscard 2026-07-26-10:34: -In-app task-view re-entry freshness is deliberately NOT the hydration TTL. `SWR_TASKS_MAX_AGE_MS` was -raised to hours so a discarded mobile tab can repaint its last board instantly; this bound answers a -different question — "is the LIVE in-memory snapshot recent enough to skip the catch-up fetch when the -user returns to Board/List within the same page session?" — and must stay short, because task SSE is -disabled off task-list views and missed events need server confirmation. -*/ -const TASK_VIEW_REENTRY_FRESHNESS_MS = 60_000; /* FNXC:MobileTabDiscard 2026-07-26-16:40: @@ -395,6 +386,8 @@ export function useTasks(options?: UseTasksOptions) { const searchQueryRef = useRef(searchQuery); const refreshTasksRef = useRef(null!); const prevSseEnabledRef = useRef(sseEnabled); + // Coordinates the earlier re-entry effect with the project-change fetch effect below. + const projectChangeRefreshPendingRef = useRef(false); /* FNXC:MobileTabDiscard 2026-07-26-14:12: "Data as of" clock for everything derived from `tasks` (isTaskStuck / countStuckTasks, TaskCard's @@ -474,6 +467,7 @@ export function useTasks(options?: UseTasksOptions) { if (previousProjectIdRef.current !== projectId) { previousProjectIdRef.current = projectId; projectContextVersionRef.current++; + projectChangeRefreshPendingRef.current = true; } const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; @@ -586,35 +580,25 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); refreshTasksRef.current = refreshTasks; - const shouldRefreshOnTaskViewReentry = useCallback(() => { - if (lastRefreshErrorAt !== null) return true; - if (searchQueryRef.current) return true; - if (includeArchivedRef.current) return true; - if (lastConfirmedProjectIdRef.current !== projectId) return true; - if (lastConfirmedSearchQueryRef.current !== searchQueryRef.current) return true; - if (lastConfirmedIncludeArchivedRef.current !== includeArchivedRef.current) return true; - - const lastFetchAt = lastFetchTimeMs.current; - if (lastFetchAt === undefined) return true; - - return Date.now() - lastFetchAt > TASK_VIEW_REENTRY_FRESHNESS_MS; - }, [lastRefreshErrorAt, projectId]); - /* - FNXC:DashboardTaskCache 2026-06-29-22:35: - Brief Board/List returns should reuse fresh in-memory task state instead of issuing another all-task fetch, so the existing task array renders immediately without an empty/loading shell. Stale, missing, failed, project/search, or archived snapshots still perform one catch-up because task SSE is disabled off task-list views and missed events need server confirmation. + FNXC:DashboardLiveUpdates 2026-08-04-08:12: + Task SSE is disabled outside Board/List, so elapsed time cannot prove that the in-memory snapshot is + current: any task lifecycle event may have been missed during that lossy interval. Every genuine + false→true task-view return therefore reconciles once with the server, regardless of snapshot age. - FNXC:DashboardTaskCache 2026-06-29-23:12: - The freshness shortcut is scoped only to in-app task-view re-entry. Initial mount, tab visibility recovery, SSE reconnect resync, search refreshes, and delete fetch-version invalidation remain independent safety paths because each represents either a new browser/server gap or a changed query context. + The project-change effect is deliberately later in this hook. When a project switch and false→true + return occur in one render, it owns the single new-project request; this effect skips that coincident + transition rather than issuing a duplicate. Initial true mounts, false→false renders, and Board↔List + true→true renders are not re-entries, while a later same-project false→true transition remains eligible. */ useEffect(() => { const previous = prevSseEnabledRef.current; prevSseEnabledRef.current = sseEnabled; - if (previous === false && sseEnabled === true && shouldRefreshOnTaskViewReentry()) { + if (previous === false && sseEnabled === true && !projectChangeRefreshPendingRef.current) { void refreshTasksRef.current(); } - }, [shouldRefreshOnTaskViewReentry, sseEnabled]); + }, [sseEnabled]); /* FNXC:ArchivePagination 2026-07-08-00:00: @@ -754,6 +738,7 @@ export function useTasks(options?: UseTasksOptions) { useEffect(() => { setIsStale(true); void refreshTasks({ clearOnError: true }); + projectChangeRefreshPendingRef.current = false; // FNXC:ArchivePagination 2026-07-08-00:00: reset archived-page state on // project switch so a new project's Archived column starts collapsed // and re-fetches its own page 1 rather than reusing the previous