Implement visibility-aware polling to reduce background tab resource usage
- Add usePageVisibility hook to track page visibility state - Add pauseWhenHidden option to usePolledData (defaults to false for backward compatibility) - When enabled, polling pauses when page is hidden and resumes with immediate refresh when visible - Refactor useBlocklistStatus to use usePolledData with pauseWhenHidden=true - Add comprehensive tests for usePageVisibility hook - Add polling lifecycle documentation to Web-Development.md Fixes #36: Polling continues when tab is not visible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1010,6 +1010,45 @@ const load = useCallback(() => {
|
|||||||
}, []);
|
}, []);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Polling Lifecycle & Visibility-Aware Polling
|
||||||
|
|
||||||
|
Regular polling hooks (`usePolledData`, `useBlocklistStatus`, etc.) can consume significant backend resources and client CPU when running in background tabs. To optimize resource usage:
|
||||||
|
|
||||||
|
**`usePolledData` with `pauseWhenHidden`:**
|
||||||
|
|
||||||
|
The `usePolledData` hook accepts a `pauseWhenHidden` option (defaults to `false` for backward compatibility). When enabled:
|
||||||
|
|
||||||
|
- Polling **pauses** when the page becomes hidden (user switched tabs).
|
||||||
|
- Polling **resumes immediately** when the page becomes visible, with a fresh fetch to ensure data is current.
|
||||||
|
- This significantly reduces unnecessary backend load and client resource usage in background tabs.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Poll server status every 30 seconds, pause when tab is hidden
|
||||||
|
const { data: status, loading, error } = usePolledData({
|
||||||
|
fetcher: (signal) => fetchServerStatus(signal),
|
||||||
|
selector: (response) => response.status,
|
||||||
|
errorMessage: "Failed to load server status",
|
||||||
|
pollInterval: 30_000,
|
||||||
|
pauseWhenHidden: true, // Pause in background tabs
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Existing hooks that enable this by default:**
|
||||||
|
|
||||||
|
- `useBlocklistStatus`: Pauses the 60-second blocklist import error polling when hidden.
|
||||||
|
|
||||||
|
**Design considerations:**
|
||||||
|
|
||||||
|
- **Backward compatibility:** `pauseWhenHidden` defaults to `false`. Existing hooks like `useServerStatus` that poll continuously are unaffected.
|
||||||
|
- **On visibility restore:** The hook triggers an immediate refresh to detect any changes that occurred while hidden. This ensures data is current, not stale.
|
||||||
|
- **Edge cases:** Picture-in-Picture windows are treated as hidden (data is paused). If critical alerts require constant monitoring, use `pauseWhenHidden: false`.
|
||||||
|
|
||||||
|
**Internal implementation:**
|
||||||
|
|
||||||
|
The `usePageVisibility` hook tracks the `document.hidden` state via the `visibilitychange` event. This is widely supported (IE 10+) and has no performance overhead — just a single event listener per component tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Session Validation on App Mount
|
### Session Validation on App Mount
|
||||||
|
|
||||||
The `AuthProvider` uses the `useSessionValidation` hook to validate the cached session with the backend on app mount. This pattern ensures that the UI state always reflects reality — expired or revoked sessions are detected immediately, not after the first API call.
|
The `AuthProvider` uses the `useSessionValidation` hook to validate the cached session with the backend on app mount. This pattern ensures that the UI state always reflects reality — expired or revoked sessions are detected immediately, not after the first API call.
|
||||||
|
|||||||
110
frontend/src/hooks/__tests__/usePageVisibility.test.ts
Normal file
110
frontend/src/hooks/__tests__/usePageVisibility.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { usePageVisibility } from "../usePageVisibility";
|
||||||
|
|
||||||
|
describe("usePageVisibility", () => {
|
||||||
|
let visibilityChangeListeners: Array<(event: Event) => void> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
visibilityChangeListeners = [];
|
||||||
|
|
||||||
|
// Mock document.hidden
|
||||||
|
Object.defineProperty(document, "hidden", {
|
||||||
|
configurable: true,
|
||||||
|
get: vi.fn(() => false),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock addEventListener
|
||||||
|
vi.spyOn(document, "addEventListener").mockImplementation((event, listener) => {
|
||||||
|
if (event === "visibilitychange") {
|
||||||
|
visibilityChangeListeners.push(listener as (event: Event) => void);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(document, "removeEventListener");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
visibilityChangeListeners = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with correct visibility state", () => {
|
||||||
|
const { result } = renderHook(() => usePageVisibility());
|
||||||
|
expect(result.current).toBe(true); // not hidden on init
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when document.hidden is true", () => {
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(true);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePageVisibility());
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches visibilitychange listener on mount", () => {
|
||||||
|
renderHook(() => usePageVisibility());
|
||||||
|
|
||||||
|
expect(document.addEventListener).toHaveBeenCalledWith(
|
||||||
|
"visibilitychange",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes visibilitychange listener on unmount", () => {
|
||||||
|
const { unmount } = renderHook(() => usePageVisibility());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.removeEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates state when page becomes hidden", () => {
|
||||||
|
const { result } = renderHook(() => usePageVisibility());
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
|
||||||
|
// Simulate visibility change
|
||||||
|
act(() => {
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(true);
|
||||||
|
visibilityChangeListeners.forEach((listener) => {
|
||||||
|
listener(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates state when page becomes visible", () => {
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(true);
|
||||||
|
const { result } = renderHook(() => usePageVisibility());
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
|
||||||
|
// Simulate visibility change
|
||||||
|
act(() => {
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(false);
|
||||||
|
visibilityChangeListeners.forEach((listener) => {
|
||||||
|
listener(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple rapid visibility changes", () => {
|
||||||
|
const { result } = renderHook(() => usePageVisibility());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Hidden
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(true);
|
||||||
|
visibilityChangeListeners.forEach((listener) => listener(new Event("visibilitychange")));
|
||||||
|
// Visible
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(false);
|
||||||
|
visibilityChangeListeners.forEach((listener) => listener(new Event("visibilitychange")));
|
||||||
|
// Hidden again
|
||||||
|
(Object.getOwnPropertyDescriptor(document, "hidden")?.get as any).mockReturnValue(true);
|
||||||
|
visibilityChangeListeners.forEach((listener) => listener(new Event("visibilitychange")));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* React hook for polling blocklist schedule error state.
|
* React hook for polling blocklist schedule error state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { usePolledData } from "./usePolledData";
|
||||||
import { fetchSchedule } from "../api/blocklist";
|
import { fetchSchedule } from "../api/blocklist";
|
||||||
|
|
||||||
const BLOCKLIST_POLL_INTERVAL_MS = 60_000;
|
const BLOCKLIST_POLL_INTERVAL_MS = 60_000;
|
||||||
@@ -14,36 +14,17 @@ export interface UseBlocklistStatusReturn {
|
|||||||
/**
|
/**
|
||||||
* Poll `GET /api/blocklists/schedule` every 60 seconds to detect whether
|
* Poll `GET /api/blocklists/schedule` every 60 seconds to detect whether
|
||||||
* the most recent blocklist import had errors.
|
* the most recent blocklist import had errors.
|
||||||
|
*
|
||||||
|
* Polling pauses when the page is hidden and resumes immediately when visible.
|
||||||
*/
|
*/
|
||||||
export function useBlocklistStatus(): UseBlocklistStatusReturn {
|
export function useBlocklistStatus(): UseBlocklistStatusReturn {
|
||||||
const [hasErrors, setHasErrors] = useState(false);
|
const { data } = usePolledData({
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
fetcher: (signal) => fetchSchedule(signal),
|
||||||
|
selector: (response) => response.last_run_errors === true,
|
||||||
useEffect(() => {
|
errorMessage: "Failed to fetch blocklist schedule",
|
||||||
const poll = (): void => {
|
pollInterval: BLOCKLIST_POLL_INTERVAL_MS,
|
||||||
abortRef.current?.abort();
|
pauseWhenHidden: true,
|
||||||
const controller = new AbortController();
|
|
||||||
abortRef.current = controller;
|
|
||||||
|
|
||||||
fetchSchedule(controller.signal)
|
|
||||||
.then((info) => {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setHasErrors(info.last_run_errors === true);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Silently swallow network errors — do not change indicator state.
|
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
return { hasErrors: data ?? false };
|
||||||
const id = window.setInterval(poll, BLOCKLIST_POLL_INTERVAL_MS);
|
|
||||||
return (): void => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
window.clearInterval(id);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { hasErrors };
|
|
||||||
}
|
}
|
||||||
|
|||||||
32
frontend/src/hooks/usePageVisibility.ts
Normal file
32
frontend/src/hooks/usePageVisibility.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Hook that tracks whether the current page is visible to the user.
|
||||||
|
*
|
||||||
|
* Returns true when the page is active and visible, false when the user has switched
|
||||||
|
* to another tab, window, or minimized the browser.
|
||||||
|
*
|
||||||
|
* Uses the Document.hidden API with the `visibilitychange` event.
|
||||||
|
* See: https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track whether the page is currently visible to the user.
|
||||||
|
*
|
||||||
|
* @returns True if the page is visible, false if hidden
|
||||||
|
*/
|
||||||
|
export function usePageVisibility(): boolean {
|
||||||
|
const [isVisible, setIsVisible] = useState(!document.hidden);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = (): void => {
|
||||||
|
setIsVisible(!document.hidden);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isVisible;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useFetchData } from "./useFetchData";
|
import { useFetchData } from "./useFetchData";
|
||||||
|
import { usePageVisibility } from "./usePageVisibility";
|
||||||
import type { FetchError } from "../types/api";
|
import type { FetchError } from "../types/api";
|
||||||
|
|
||||||
export interface UsePolledDataOptions<TResponse, TData> {
|
export interface UsePolledDataOptions<TResponse, TData> {
|
||||||
@@ -23,6 +24,12 @@ export interface UsePolledDataOptions<TResponse, TData> {
|
|||||||
pollInterval?: number;
|
pollInterval?: number;
|
||||||
/** If true, automatically refetch when browser window regains focus. Defaults to true. */
|
/** If true, automatically refetch when browser window regains focus. Defaults to true. */
|
||||||
refetchOnWindowFocus?: boolean;
|
refetchOnWindowFocus?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, pause polling when the page is hidden (user switched to another tab).
|
||||||
|
* Polling resumes immediately when the page becomes visible, with an immediate refresh.
|
||||||
|
* Defaults to false for backward compatibility.
|
||||||
|
*/
|
||||||
|
pauseWhenHidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsePolledDataResult<TData> {
|
export interface UsePolledDataResult<TData> {
|
||||||
@@ -47,6 +54,9 @@ export interface UsePolledDataResult<TData> {
|
|||||||
* - `"network_error"`: Network, DNS, or JSON parse failure
|
* - `"network_error"`: Network, DNS, or JSON parse failure
|
||||||
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
|
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
|
||||||
*
|
*
|
||||||
|
* When `pauseWhenHidden` is enabled, polling pauses when the page becomes hidden
|
||||||
|
* and resumes immediately with a fresh fetch when the page becomes visible again.
|
||||||
|
*
|
||||||
* @param options - Configuration options
|
* @param options - Configuration options
|
||||||
* @returns Data, loading state, typed error, and refresh callback
|
* @returns Data, loading state, typed error, and refresh callback
|
||||||
*/
|
*/
|
||||||
@@ -61,6 +71,7 @@ export function usePolledData<TResponse, TData>(
|
|||||||
initialData,
|
initialData,
|
||||||
pollInterval,
|
pollInterval,
|
||||||
refetchOnWindowFocus = true,
|
refetchOnWindowFocus = true,
|
||||||
|
pauseWhenHidden = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const { data, loading, error, refresh } = useFetchData({
|
const { data, loading, error, refresh } = useFetchData({
|
||||||
@@ -72,25 +83,61 @@ export function usePolledData<TResponse, TData>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const refreshRef = useRef(refresh);
|
const refreshRef = useRef(refresh);
|
||||||
|
const intervalIdRef = useRef<number | null>(null);
|
||||||
|
const previousVisibilityRef = useRef<boolean | null>(null);
|
||||||
|
const isVisible = usePageVisibility();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshRef.current = refresh;
|
refreshRef.current = refresh;
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Polling effect: set up interval if pollInterval is provided
|
// Polling effect: set up interval if pollInterval is provided and page is visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pollInterval) {
|
if (!pollInterval) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = setInterval((): void => {
|
// If pauseWhenHidden is enabled and page is hidden, clear any existing interval
|
||||||
|
if (pauseWhenHidden && !isVisible) {
|
||||||
|
if (intervalIdRef.current !== null) {
|
||||||
|
clearInterval(intervalIdRef.current);
|
||||||
|
intervalIdRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = window.setInterval((): void => {
|
||||||
refreshRef.current();
|
refreshRef.current();
|
||||||
}, pollInterval);
|
}, pollInterval);
|
||||||
|
|
||||||
|
intervalIdRef.current = id;
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
|
if (intervalIdRef.current === id) {
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
|
intervalIdRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [pollInterval]);
|
}, [pollInterval, pauseWhenHidden, isVisible]);
|
||||||
|
|
||||||
|
// Visibility effect: handle pause/resume when page visibility changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pauseWhenHidden || !pollInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only refresh if this is a transition from hidden to visible, not on mount
|
||||||
|
if (previousVisibilityRef.current === false && isVisible) {
|
||||||
|
// Page became visible: clear pending interval and refresh immediately
|
||||||
|
if (intervalIdRef.current !== null) {
|
||||||
|
clearInterval(intervalIdRef.current);
|
||||||
|
intervalIdRef.current = null;
|
||||||
|
}
|
||||||
|
refreshRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousVisibilityRef.current = isVisible;
|
||||||
|
}, [isVisible, pauseWhenHidden, pollInterval]);
|
||||||
|
|
||||||
// Window focus: optional refetch on regain focus
|
// Window focus: optional refetch on regain focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user