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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 01:30:33 -07:00
parent c10a880fc9
commit ad2cb6958e
2 changed files with 54 additions and 76 deletions

View File

@@ -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<Task[]>((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");

View File

@@ -12,15 +12,6 @@ import { isLikelyTabSuspensionError } from "./visibilitySuspension";
import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles";
const loggedTaskCacheHitProjects = new Set<string>();
/*
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<typeof refreshTasks>(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