feat(KB-662): refresh tasks when the dashboard regains focus
- Add a debounced visibilitychange refresh path to useTasks - Reuse task normalization when refreshing task data from the API - Preserve project-scoped fetching during initial and visibility-triggered reloads - Add hook tests covering visibility refresh, normalization, debounce, and cleanup
This commit is contained in:
@@ -837,38 +837,54 @@ describe("useTasks", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchVisibilityChange() {
|
||||
async function dispatchVisibilityChange() {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("refetches tasks when visibility changes from hidden to visible", async () => {
|
||||
it("refetches tasks when visibility changes from hidden to visible and normalizes refreshed data", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||
const refreshedTask = createMockTask({
|
||||
const refreshedTask = {
|
||||
...createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
}),
|
||||
dependencies: undefined,
|
||||
steps: undefined,
|
||||
log: undefined,
|
||||
} as unknown as Task;
|
||||
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]).mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Reset mock to return refreshed data
|
||||
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
expect(result.current.tasks[0].dependencies).toEqual([]);
|
||||
expect(result.current.tasks[0].steps).toEqual([]);
|
||||
expect(result.current.tasks[0].log).toEqual([]);
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -878,53 +894,60 @@ describe("useTasks", () => {
|
||||
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
setVisibilityState("visible");
|
||||
setVisibilityState("hidden");
|
||||
dispatchVisibilityChange();
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
// Should not trigger another fetch
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
mockFetchTasks.mockResolvedValue([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Wait for 1 second to ensure debounce window has passed from initial fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Reset mock to track new calls
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
// First visibility change should trigger a fetch (1s has passed)
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rapid visibility changes immediately after should be debounced
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
await dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
// Should still only be 1 call (debounced)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
|
||||
@@ -39,42 +39,43 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Ref to track last visibility fetch time for debouncing (1 second minimum)
|
||||
const lastVisibilityFetchRef = useRef<number>(0);
|
||||
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
|
||||
// Ref to track last visibility refresh time for debouncing (1 second minimum)
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Determine which fetch function to use
|
||||
const fetchTasksFn = useCallback(() => {
|
||||
if (projectId) {
|
||||
return api.fetchProjectTasks(projectId);
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean }) => {
|
||||
try {
|
||||
const refreshedTasks = projectId
|
||||
? await api.fetchProjectTasks(projectId)
|
||||
: await api.fetchTasks();
|
||||
setTasks(refreshedTasks.map(normalizeTask));
|
||||
} catch {
|
||||
if (options?.clearOnError) {
|
||||
setTasks([]);
|
||||
}
|
||||
}
|
||||
return api.fetchTasks();
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => setTasks([]));
|
||||
}, [fetchTasksFn]);
|
||||
void refreshTasks({ clearOnError: true });
|
||||
}, [refreshTasks]);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
|
||||
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
|
||||
lastVisibilityFetchRef.current = now;
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => {
|
||||
// Silently ignore fetch errors on visibility change
|
||||
});
|
||||
}
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibilityRefreshRef.current = now;
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
@@ -82,7 +83,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [fetchTasksFn]);
|
||||
}, [refreshTasks]);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
|
||||
Reference in New Issue
Block a user