feat(KB-662): add visibility change listener with debouncing to useTasks hook

- Add visibilitychange event listener to refresh tasks when tab becomes visible
- Implement 1-second debounce to prevent excessive fetch requests
- Add comprehensive tests for visibility change behavior and debouncing
- Gracefully handle fetch errors during visibility changes
This commit is contained in:
gsxdsm
2026-03-31 22:50:27 -07:00
parent ef9ecdbe08
commit 954bb16cf4
2 changed files with 166 additions and 0 deletions

View File

@@ -809,4 +809,140 @@ describe("useTasks", () => {
expect(result.current.tasks[0].column).toBe("todo");
});
});
describe("visibility change", () => {
let originalVisibilityState: PropertyDescriptor | undefined;
beforeEach(() => {
// Store original descriptor to restore later
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
});
afterEach(() => {
// Restore original visibilityState property
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
// If no original descriptor, just delete our mock
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
}
});
function setVisibilityState(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
value: state,
writable: true,
configurable: true,
});
}
function dispatchVisibilityChange() {
document.dispatchEvent(new Event("visibilitychange"));
}
it("refetches tasks when visibility changes from hidden to visible", async () => {
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
const refreshedTask = createMockTask({
id: "FN-001",
column: "in-progress" as Column,
updatedAt: "2026-01-02T00:00:00Z",
});
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
// Reset mock to return refreshed data
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
// Simulate tab becoming visible
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
await waitFor(() => {
expect(result.current.tasks[0].column).toBe("in-progress");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
it("does not refetch when visibility changes to hidden", async () => {
const initialTask = createMockTask({ id: "FN-001" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
renderHook(() => useTasks());
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
// Simulate tab becoming hidden
setVisibilityState("visible");
setVisibilityState("hidden");
dispatchVisibilityChange();
// Should not trigger another fetch
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
const initialTask = createMockTask({ id: "FN-001" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
// 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)
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
// Rapid visibility changes immediately after should be debounced
for (let i = 0; i < 5; i++) {
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
}
// Should still only be 1 call (debounced)
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("cleans up visibility change listener on unmount", async () => {
mockFetchTasks.mockResolvedValueOnce([]);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useTasks());
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
});

View File

@@ -30,11 +30,41 @@ export function useTasks() {
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;
// Fetch initial tasks
useEffect(() => {
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
}, []);
// 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;
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
lastVisibilityFetchRef.current = now;
api.fetchTasks()
.then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => {
// Silently ignore fetch errors on visibility change
});
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
// SSE live updates
useEffect(() => {
let closedByCleanup = false;