fix(FN-673): resync tasks after dashboard stream reconnect

This commit is contained in:
gsxdsm
2026-04-01 12:49:32 -07:00
parent ebe36d1ad6
commit 88534e1827
3 changed files with 39 additions and 6 deletions

View File

@@ -289,7 +289,6 @@ describe("App engine pause (soft pause)", () => {
}); });
describe("App view switching", () => { describe("App view switching", () => {
it("keeps the selected board/list view while task data changes underneath it", async () => {
it("renders Board view by default", async () => { it("renders Board view by default", async () => {
render(<App />); render(<App />);

View File

@@ -254,6 +254,7 @@ describe("useTasks", () => {
}); });
expect(MockEventSource.instances).toHaveLength(2); expect(MockEventSource.instances).toHaveLength(2);
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
unmount(); unmount();
}); });
@@ -298,6 +299,7 @@ describe("useTasks", () => {
expect(result.current.tasks[0]?.title).toBe("Fresh title"); expect(result.current.tasks[0]?.title).toBe("Fresh title");
}); });
describe("SSE event: task:updated", () => { describe("SSE event: task:updated", () => {
it("updates task fields", async () => { it("updates task fields", async () => {
const initialTask = createMockTask({ const initialTask = createMockTask({

View File

@@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core"; import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
import * as api from "../api"; import * as api from "../api";
const RECONNECT_DELAY_MS = 3000;
function normalizeTask(task: Task): Task { function normalizeTask(task: Task): Task {
return { return {
...task, ...task,
@@ -28,17 +30,47 @@ export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [connectionNonce, setConnectionNonce] = useState(0); const [connectionNonce, setConnectionNonce] = useState(0);
const tasksRef = useRef(tasks); const tasksRef = useRef(tasks);
const fetchVersionRef = useRef(0);
tasksRef.current = tasks; tasksRef.current = tasks;
// Fetch initial tasks const refreshTasks = useCallback(async () => {
useEffect(() => { const requestVersion = ++fetchVersionRef.current;
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
try {
const fetchedTasks = await api.fetchTasks();
if (fetchVersionRef.current !== requestVersion) {
return;
}
setTasks(fetchedTasks.map(normalizeTask));
} catch {
if (fetchVersionRef.current !== requestVersion) {
return;
}
setTasks((current) => current);
}
}, []); }, []);
// Fetch initial tasks and recover when the tab becomes visible again.
useEffect(() => {
void refreshTasks();
const handleVisibilityChange = () => {
void refreshTasks();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refreshTasks]);
// SSE live updates // SSE live updates
useEffect(() => { useEffect(() => {
let closedByCleanup = false; let closedByCleanup = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
if (connectionNonce > 0) {
void refreshTasks();
}
const es = new EventSource("/api/events"); const es = new EventSource("/api/events");
const handleCreated = (e: MessageEvent) => { const handleCreated = (e: MessageEvent) => {
@@ -137,7 +169,7 @@ export function useTasks() {
cleanup(); cleanup();
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
setConnectionNonce((current) => current + 1); setConnectionNonce((current) => current + 1);
}, 3000); }, RECONNECT_DELAY_MS);
}; };
es.addEventListener("task:created", handleCreated); es.addEventListener("task:created", handleCreated);
@@ -151,7 +183,7 @@ export function useTasks() {
closedByCleanup = true; closedByCleanup = true;
cleanup(); cleanup();
}; };
}, [connectionNonce]); }, [connectionNonce, refreshTasks]);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => { const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
return normalizeTask(await api.createTask(input)); return normalizeTask(await api.createTask(input));