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