feat(KB-662): add visibility change listener with debouncing
- Add page visibility change detection with debounced state handling - Implement visibility listener for managing background task behavior - Add comprehensive tests for debouncing and visibility transitions - Resolve type conflicts in types.ts and SettingsModal.tsx
This commit is contained in:
@@ -809,4 +809,140 @@ describe("useTasks", () => {
|
|||||||
expect(result.current.tasks[0].column).toBe("todo");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,11 +30,41 @@ export function useTasks() {
|
|||||||
const tasksRef = useRef(tasks);
|
const tasksRef = useRef(tasks);
|
||||||
tasksRef.current = 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
|
// Fetch initial tasks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
|
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
|
// SSE live updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let closedByCleanup = false;
|
let closedByCleanup = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user