From 09e4808d7e2309c8c6a966c3956f0c4770aef92a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 5 Aug 2026 11:39:23 -0700 Subject: [PATCH] FN-8807: revalidate dashboard cards after browser resume Refresh dashboard task data when browser resume signals arrive, while retaining live SSE mutations across delayed snapshots and cache remounts. - Revalidate on focus, pageshow, visibility, and SSE reconnect with overlapping-request deduplication. - Reconcile live task creates and deletes synchronously before caching or pruning mutation fences. - Cover resume triggers, instrumentation, and cache/remount membership races. Files changed: .../fn-8807-dashboard-resume-revalidation.md | 7 + .../useTasks.resume-instrumentation.test.ts | 41 +++++ .../dashboard/app/hooks/__tests__/useTasks.test.ts | 65 ++++++++ packages/dashboard/app/hooks/useTasks.ts | 185 +++++++++++++++------ .../dashboard/app/utils/resumeInstrumentation.ts | 6 + 5 files changed, 256 insertions(+), 48 deletions(-) Fusion-Task-Id: FN-8807 Fusion-Task-Lineage: df8f097e-5067-4fcc-8023-92a2ea329b40 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8807-dashboard-resume-revalidation.md | 7 + .../useTasks.resume-instrumentation.test.ts | 41 ++++ .../app/hooks/__tests__/useTasks.test.ts | 65 ++++++ packages/dashboard/app/hooks/useTasks.ts | 185 +++++++++++++----- .../app/utils/resumeInstrumentation.ts | 6 + 5 files changed, 256 insertions(+), 48 deletions(-) create mode 100644 .changeset/fn-8807-dashboard-resume-revalidation.md diff --git a/.changeset/fn-8807-dashboard-resume-revalidation.md b/.changeset/fn-8807-dashboard-resume-revalidation.md new file mode 100644 index 0000000000..3ffc893774 --- /dev/null +++ b/.changeset/fn-8807-dashboard-resume-revalidation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Refresh dashboard cards reliably after browser and tab resume. +category: fix +dev: Unifies focus, visibility, pageshow, and SSE reconnect card revalidation with context fencing. diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.resume-instrumentation.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.resume-instrumentation.test.ts index 72fb68474b..5cf12f1775 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.resume-instrumentation.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.resume-instrumentation.test.ts @@ -84,4 +84,45 @@ describe("useTasks resume instrumentation", () => { replayAttempted: false, })); }); + + it("coalesces visibility, focus, pageshow, and reconnect bursts into one current-context fetch", async () => { + const api = await import("../../api"); + const fetchTasks = vi.mocked(api.fetchTasks); + const { useTasks } = await import("../useTasks"); + renderHook(() => useTasks({ projectId: "proj-current" })); + await waitFor(() => expect(subscribeCalls[0]?.onReconnect).toBeTypeOf("function")); + await waitFor(() => expect(fetchTasks).toHaveBeenCalled()); + fetchTasks.mockClear(); + + let resolveRefresh: (tasks: never[]) => void = () => {}; + fetchTasks.mockImplementationOnce(() => new Promise((resolve) => { resolveRefresh = resolve; })); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })); + subscribeCalls[0]?.onReconnect?.(); + }); + + expect(fetchTasks).toHaveBeenCalledTimes(1); + await act(async () => resolveRefresh([])); + }); + + describe.each([ + ["focus", () => window.dispatchEvent(new Event("focus"))], + ["pageshow", () => window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }))], + ])("%s resume instrumentation", (trigger, dispatch) => { + it("uses the production hook revalidation seam", async () => { + const { useTasks } = await import("../useTasks"); + renderHook(() => useTasks({ projectId: "proj-1", sseEnabled: false })); + + act(dispatch); + + expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({ + view: "useTasks", + trigger, + projectId: "proj-1", + replayAttempted: false, + })); + }); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 537f11cb4b..1207c94380 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -3395,6 +3395,71 @@ describe("useTasks", () => { removeEventListenerSpy.mockRestore(); }); + + describe.each([ + ["focus-only desktop return", () => window.dispatchEvent(new Event("focus"))], + ["persisted bfcache pageshow", () => window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }))], + ["non-persisted browser restore pageshow", () => window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false }))], + ])("authoritative resume via %s", (_label, resume) => { + it("converges a card that changed while its SSE event was missed", async () => { + const cached = createMockTask({ id: "FN-RESUME", column: "todo" as Column, updatedAt: "2026-01-01T00:00:00Z" }); + const authoritative = createMockTask({ id: "FN-RESUME", column: "in-progress" as Column, updatedAt: "2026-01-01T00:01:00Z" }); + mockFetchTasks.mockResolvedValueOnce([cached]).mockResolvedValueOnce([authoritative]); + + const { result } = renderHook(() => useTasks({ projectId: "resume-project", sseEnabled: false })); + await waitFor(() => expect(result.current.tasks[0]?.column).toBe("todo")); + + await act(async () => { + resume(); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.tasks[0]?.column).toBe("in-progress")); + expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, "resume-project", undefined, false); + }); + }); + + describe.each([ + ["task:created", (initial: Task[]) => createMockTask({ id: "FN-LIVE-CREATE", column: "in-progress" as Column, updatedAt: "2026-01-01T00:02:00Z" }), ["FN-BASE", "FN-LIVE-CREATE"]], + ["task:deleted", (initial: Task[]) => initial[1]!, ["FN-BASE"]], + ])("resume response after live %s", (eventName, eventTask, expectedTaskIds) => { + it("keeps the live membership in cache across a remount", async () => { + const base = createMockTask({ id: "FN-BASE", updatedAt: "2026-01-01T00:00:00Z" }); + const removable = createMockTask({ id: "FN-LIVE-DELETE", column: "todo" as Column, updatedAt: "2026-01-01T00:01:00Z" }); + const initial = eventName === "task:created" ? [base] : [base, removable]; + let cachedSnapshot: Task[] | null = null; + mockReadCache.mockImplementation(() => cachedSnapshot); + mockWriteCache.mockImplementation((_key, tasks) => { + cachedSnapshot = tasks as Task[]; + return true; + }); + let resolveResume: (tasks: Task[]) => void = () => {}; + const pendingResume = new Promise((resolve) => { resolveResume = resolve; }); + mockFetchTasks.mockResolvedValueOnce(initial).mockImplementationOnce(() => pendingResume).mockResolvedValue([]); + + const { result, unmount } = renderHook(() => useTasks({ projectId: "resume-project" })); + await waitFor(() => expect(result.current.tasks.map((task) => task.id)).toEqual(initial.map((task) => task.id))); + + act(() => { + window.dispatchEvent(new Event("focus")); + }); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalledTimes(2)); + + act(() => { + MockEventSource.instances[0]!._emit(eventName, eventTask(initial)); + }); + await act(async () => { + resolveResume(initial); + await flushPromises(); + }); + + expect(result.current.tasks.map((task) => task.id)).toEqual(expectedTaskIds); + unmount(); + + const { result: remounted } = renderHook(() => useTasks({ projectId: "resume-project", sseEnabled: false })); + expect(remounted.current.tasks.map((task) => task.id)).toEqual(expectedTaskIds); + }); + }); }); describe("project switching", () => { diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 640796146c..cd8a71a093 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -436,6 +436,18 @@ export function useTasks(options?: UseTasksOptions) { const includeArchivedRef = useRef(includeArchived); const tasksRef = useRef(tasks); const fetchVersionRef = useRef(0); + /* + FNXC:DashboardResume 2026-08-05-18:17: + A resumed list request is a point-in-time server snapshot, while task SSE is a later committed + mutation. Track the task ids changed after each request begins so its delayed response retains live + creates and excludes live deletes instead of replacing the entire board with its older membership. + A later successful fetch prunes mutations it already observed; newer mutations remain fenced until + their own authoritative response arrives. + */ + const liveMutationVersionRef = useRef(0); + const liveTaskMutationsRef = useRef(new Map()); + const mountedRef = useRef(true); + const resumeRefreshRef = useRef<{ identity: string; promise: Promise } | null>(null); // Tracks the project context version to detect stale SSE events after project switches. // Incremented whenever projectId changes, invalidating any in-flight SSE handlers. const projectContextVersionRef = useRef(0); @@ -525,6 +537,9 @@ export function useTasks(options?: UseTasksOptions) { // projectContextVersionRef before applying events. if (previousProjectIdRef.current !== projectId) { previousProjectIdRef.current = projectId; + // A request begun by the prior render still closes over its old project id. Invalidate it + // synchronously, before effects install this context's fetch, so it cannot paint old cards. + fetchVersionRef.current++; projectContextVersionRef.current++; projectChangeRefreshPendingRef.current = true; } @@ -533,6 +548,7 @@ export function useTasks(options?: UseTasksOptions) { const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string; includeArchivedOverride?: boolean }) => { const requestVersion = ++fetchVersionRef.current; + const requestLiveMutationVersion = liveMutationVersionRef.current; const requestProjectId = projectId; // Capture the projectId for this request const query = options?.searchQueryOverride ?? searchQueryRef.current; /* @@ -582,22 +598,45 @@ export function useTasks(options?: UseTasksOptions) { the query is cleared. */ const shouldCarryOverArchived = !wantArchived && !query && archivedLoadedRef.current; - setTasks((previous) => { - // A scheduler/SWR response can have been assembled before a newer SSE/local update. - // Reconcile matching rows inside the state updater so that race cannot roll an open detail - // (or its board source) back merely because this fetch callback arrived last. - const reconciledFetchedTasks = normalizedFetchedTasks.map((fetched) => { - const current = previous.find((candidate) => candidate.id === fetched.id); - return current ? mergeIncomingTask(current, fetched, { fullSnapshot: true }) : fetched; - }); - if (!shouldCarryOverArchived) return reconciledFetchedTasks; - - const freshIds = new Set(reconciledFetchedTasks.map((task) => task.id)); - const archivedCarryOver = archivedTasksRef.current.filter((task) => !freshIds.has(task.id)); - return archivedCarryOver.length > 0 ? [...reconciledFetchedTasks, ...archivedCarryOver] : reconciledFetchedTasks; + /* + FNXC:DashboardResume 2026-08-05-18:36: + React may defer a state updater, but the cache and mutation-fence cleanup run in this same + callback. Reconcile from the synchronous task/mutation refs before either side effect, so a + remount cannot hydrate the older response after an intervening SSE create or delete. + */ + const previousById = new Map(tasksRef.current.map((task) => [task.id, task])); + const fetchedIds = new Set(normalizedFetchedTasks.map((task) => task.id)); + const reconciledFetchedTasks = normalizedFetchedTasks.flatMap((fetched) => { + const liveMutation = liveTaskMutationsRef.current.get(fetched.id); + if (liveMutation && liveMutation.version > requestLiveMutationVersion) { + return liveMutation.deleted ? [] : [liveMutation.task ?? previousById.get(fetched.id) ?? fetched]; + } + const current = previousById.get(fetched.id); + return [current ? mergeIncomingTask(current, fetched, { fullSnapshot: true }) : fetched]; }); + for (const [taskId, liveMutation] of liveTaskMutationsRef.current) { + if (!fetchedIds.has(taskId) && liveMutation.version > requestLiveMutationVersion && !liveMutation.deleted) { + const task = liveMutation.task ?? previousById.get(taskId); + if (task) reconciledFetchedTasks.push(task); + } + } + const freshIds = new Set(reconciledFetchedTasks.map((task) => task.id)); + const archivedCarryOver = shouldCarryOverArchived + ? archivedTasksRef.current.filter((task) => !freshIds.has(task.id)) + : []; + const tasksForCache = reconciledFetchedTasks; + const nextTasks = archivedCarryOver.length > 0 + ? [...reconciledFetchedTasks, ...archivedCarryOver] + : reconciledFetchedTasks; + tasksRef.current = nextTasks; + setTasks(nextTasks); + for (const [taskId, mutation] of liveTaskMutationsRef.current) { + if (mutation.version <= requestLiveMutationVersion) { + liveTaskMutationsRef.current.delete(taskId); + } + } if (requestProjectId) { - writeTaskCacheSnapshot(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, fetchedTasks); + writeTaskCacheSnapshot(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, tasksForCache); } setIsStale(false); setLastRefreshErrorAt(null); @@ -646,6 +685,42 @@ export function useTasks(options?: UseTasksOptions) { }, [projectId]); refreshTasksRef.current = refreshTasks; + /* + FNXC:DashboardResume 2026-08-05-18:00: + Visibility, focus, pageshow, and an SSE reconnect are independent browser resume signals; any one + may be the only signal delivered by a desktop tab, bfcache restore, mobile PWA, or resumed socket. + They all enter this seam, which deduplicates only an overlapping request for the same captured + project/search/archive identity. A changed context, an unmounted hook, or an older request version + cannot write cards after newer server or live-event state, and a failed request clears the in-flight + marker so the next resume signal retries without blanking the usable SWR snapshot. + */ + const revalidateAfterResume = useCallback((trigger: "visibility" | "focus" | "pageshow" | "sse-reconnect", reason?: string) => { + if (!mountedRef.current) return; + const query = searchQueryRef.current; + const wantArchived = query ? archivedLoadedRef.current : includeArchivedRef.current; + const identity = `${projectContextVersionRef.current}:${projectId ?? "default"}:${query ?? ""}:${wantArchived}`; + const existing = resumeRefreshRef.current; + if (existing?.identity === identity) return; + + recordResumeEvent({ view: "useTasks", trigger, projectId, replayAttempted: false, reason }); + const promise = refreshTasksRef.current(); + resumeRefreshRef.current = { identity, promise }; + void promise.finally(() => { + if (resumeRefreshRef.current?.promise === promise) { + resumeRefreshRef.current = null; + } + }); + }, [projectId]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + fetchVersionRef.current++; + resumeRefreshRef.current = null; + }; + }, []); + /* 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 @@ -833,14 +908,7 @@ export function useTasks(options?: UseTasksOptions) { previousContextVersion, currentContextVersion: projectContextVersionRef.current, }); - recordResumeEvent({ - view: "useTasks", - trigger: "visibility", - projectId, - replayAttempted: false, - reason: "context-version-changed", - }); - void refreshTasks(); + revalidateAfterResume("visibility", "context-version-changed"); return; } @@ -851,21 +919,32 @@ export function useTasks(options?: UseTasksOptions) { } lastVisibilityRefreshRef.current = now; - recordResumeEvent({ - view: "useTasks", - trigger: "visibility", - projectId, - replayAttempted: false, - reason: "debounced-refresh", - }); - void refreshTasks(); + revalidateAfterResume("visibility", "debounced-refresh"); + }; + + const handleFocus = () => { + if (document.visibilityState === "visible") { + revalidateAfterResume("focus", "focus-return"); + } + }; + const handlePageShow = (event: PageTransitionEvent) => { + if (document.visibilityState !== "hidden") { + revalidateAfterResume("pageshow", event.persisted ? "bfcache-restore" : "browser-restore"); + } }; document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleFocus); + window.addEventListener("pageshow", handlePageShow); return () => { + // Effects clean up before a project replacement or unmount. Invalidate the captured request + // so a late server response cannot write to the next context (or a disposed hook). + fetchVersionRef.current++; document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("pageshow", handlePageShow); }; - }, [refreshTasks]); + }, [refreshTasks, revalidateAfterResume]); // SSE live updates // Note: SSE events from stale project contexts are ignored via projectContextVersionRef. @@ -896,22 +975,32 @@ export function useTasks(options?: UseTasksOptions) { // Guard against stale callbacks: when sseEnabled flips false or the // effect unmounts, these handlers must not fire refreshTasks into a // missions-only view where the SSE should be inactive. + const recordLiveMutation = (task: Task, deleted: boolean) => { + const version = ++liveMutationVersionRef.current; + liveTaskMutationsRef.current.set(task.id, { version, deleted, task: deleted ? undefined : task }); + }; + const applyLiveTasks = (update: (current: Task[]) => Task[]) => { + const nextTasks = update(tasksRef.current); + tasksRef.current = nextTasks; + setTasks(nextTasks); + }; const handleCreated = (e: MessageEvent) => { if (isStale()) { traceDroppedStaleEvent(); return; } const task = normalizeTask(JSON.parse(e.data) as Task); + recordLiveMutation(task, isSoftDeleted(task)); if (searchQueryRef.current) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } if (isSoftDeleted(task)) { - setTasks((prev) => prev.filter((candidate) => candidate.id !== task.id)); + applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== task.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:created", id: task.id }); return; } - setTasks((prev) => { + applyLiveTasks((prev) => { const existingIndex = prev.findIndex((candidate) => candidate.id === task.id); if (existingIndex === -1) { return [...prev, task]; @@ -943,7 +1032,8 @@ export function useTasks(options?: UseTasksOptions) { const { task, to }: { task: Task; from: ColumnId; to: ColumnId } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); if (isSoftDeleted(normalizedTask)) { - setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); + recordLiveMutation(normalizedTask, true); + applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:moved", id: normalizedTask.id }); return; } @@ -951,7 +1041,8 @@ export function useTasks(options?: UseTasksOptions) { // back to the task's current column. The old normalizeColumn (deleted in U12) would drop custom ids. const nextColumn: ColumnId = typeof to === "string" && to ? to : normalizedTask.column; const movedTask = { ...normalizedTask, column: nextColumn }; - setTasks((prev) => { + recordLiveMutation(movedTask, false); + applyLiveTasks((prev) => { const existingIndex = prev.findIndex((t) => t.id === movedTask.id); if (existingIndex === -1) { // SSE created event was missed (e.g., reconnect gap); upsert so the @@ -978,13 +1069,14 @@ export function useTasks(options?: UseTasksOptions) { return; } const incoming = normalizeTask(JSON.parse(e.data) as Task); + recordLiveMutation(incoming, isSoftDeleted(incoming)); if (isSoftDeleted(incoming)) { // FN-5135: treat deletedAt-bearing task:updated payloads as delete-equivalent. - setTasks((prev) => prev.filter((candidate) => candidate.id !== incoming.id)); + applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== incoming.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:updated", id: incoming.id }); return; } - setTasks((prev) => { + applyLiveTasks((prev) => { const existingIndex = prev.findIndex((t) => t.id === incoming.id); if (existingIndex === -1) { return [...prev, incoming]; @@ -1009,7 +1101,8 @@ export function useTasks(options?: UseTasksOptions) { return; } const task = normalizeTask(JSON.parse(e.data) as Task); - setTasks((prev) => prev.filter((t) => t.id !== task.id)); + recordLiveMutation(task, true); + applyLiveTasks((prev) => prev.filter((t) => t.id !== task.id)); }; const handleMerged = (e: MessageEvent) => { @@ -1024,12 +1117,14 @@ export function useTasks(options?: UseTasksOptions) { const { task }: { task: Task } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); if (isSoftDeleted(normalizedTask)) { - setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); + recordLiveMutation(normalizedTask, true); + applyLiveTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:merged", id: normalizedTask.id }); return; } const mergedTask = { ...normalizedTask, column: "done" as Column }; - setTasks((prev) => { + recordLiveMutation(mergedTask, false); + applyLiveTasks((prev) => { const existingIndex = prev.findIndex((t) => t.id === mergedTask.id); if (existingIndex === -1) { return [...prev, mergedTask]; @@ -1080,20 +1175,14 @@ export function useTasks(options?: UseTasksOptions) { traceDroppedStaleEvent(); return; } - recordResumeEvent({ - view: "useTasks", - trigger: "sse-reconnect", - projectId, - replayAttempted: false, - }); - void refreshTasksRef.current(); + revalidateAfterResume("sse-reconnect", "stream-reopened"); }, }); return () => { active = false; unsubscribe(); }; - }, [projectId, sseEnabled]); + }, [projectId, sseEnabled, revalidateAfterResume]); const createTask = useCallback(async (input: TaskCreateInput): Promise => { const task = normalizeTask(await api.createTask(input, projectId)); diff --git a/packages/dashboard/app/utils/resumeInstrumentation.ts b/packages/dashboard/app/utils/resumeInstrumentation.ts index d4dd183f75..404ddfb5b0 100644 --- a/packages/dashboard/app/utils/resumeInstrumentation.ts +++ b/packages/dashboard/app/utils/resumeInstrumentation.ts @@ -1,7 +1,13 @@ import { pushTrace } from "./dashboardTraceBuffer"; +/* +FNXC:DashboardResume 2026-08-05-18:08: +Focus-only tab returns are a supported card-resume path distinct from visibility and pageshow. Keep the +trace vocabulary explicit so operators can distinguish its authoritative revalidation from SSE recovery. +*/ export type ResumeTrigger = | "visibility" + | "focus" | "pageshow" | "sse-error" | "sse-reconnect"