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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8807-dashboard-resume-revalidation.md
Normal file
7
.changeset/fn-8807-dashboard-resume-revalidation.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Task[]>((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", () => {
|
||||
|
||||
@@ -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<string, { version: number; deleted: boolean; task?: Task }>());
|
||||
const mountedRef = useRef(true);
|
||||
const resumeRefreshRef = useRef<{ identity: string; promise: Promise<void> } | 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<Task> => {
|
||||
const task = normalizeTask(await api.createTask(input, projectId));
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user