diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 7f60046..480145a 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -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 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. diff --git a/frontend/src/hooks/__tests__/usePageVisibility.test.ts b/frontend/src/hooks/__tests__/usePageVisibility.test.ts new file mode 100644 index 0000000..cd8e225 --- /dev/null +++ b/frontend/src/hooks/__tests__/usePageVisibility.test.ts @@ -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); + }); +}); diff --git a/frontend/src/hooks/useBlocklistStatus.ts b/frontend/src/hooks/useBlocklistStatus.ts index f28d39f..e16505b 100644 --- a/frontend/src/hooks/useBlocklistStatus.ts +++ b/frontend/src/hooks/useBlocklistStatus.ts @@ -2,7 +2,7 @@ * React hook for polling blocklist schedule error state. */ -import { useEffect, useRef, useState } from "react"; +import { usePolledData } from "./usePolledData"; import { fetchSchedule } from "../api/blocklist"; 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 * the most recent blocklist import had errors. + * + * Polling pauses when the page is hidden and resumes immediately when visible. */ export function useBlocklistStatus(): UseBlocklistStatusReturn { - const [hasErrors, setHasErrors] = useState(false); - const abortRef = useRef(null); + const { data } = usePolledData({ + fetcher: (signal) => fetchSchedule(signal), + selector: (response) => response.last_run_errors === true, + errorMessage: "Failed to fetch blocklist schedule", + pollInterval: BLOCKLIST_POLL_INTERVAL_MS, + pauseWhenHidden: true, + }); - useEffect(() => { - const poll = (): void => { - abortRef.current?.abort(); - 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(); - const id = window.setInterval(poll, BLOCKLIST_POLL_INTERVAL_MS); - return (): void => { - abortRef.current?.abort(); - window.clearInterval(id); - }; - }, []); - - return { hasErrors }; + return { hasErrors: data ?? false }; } diff --git a/frontend/src/hooks/usePageVisibility.ts b/frontend/src/hooks/usePageVisibility.ts new file mode 100644 index 0000000..2c299da --- /dev/null +++ b/frontend/src/hooks/usePageVisibility.ts @@ -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; +} diff --git a/frontend/src/hooks/usePolledData.ts b/frontend/src/hooks/usePolledData.ts index 07f4597..bfe9e12 100644 --- a/frontend/src/hooks/usePolledData.ts +++ b/frontend/src/hooks/usePolledData.ts @@ -6,6 +6,7 @@ */ import { useEffect, useRef } from "react"; import { useFetchData } from "./useFetchData"; +import { usePageVisibility } from "./usePageVisibility"; import type { FetchError } from "../types/api"; export interface UsePolledDataOptions { @@ -23,6 +24,12 @@ export interface UsePolledDataOptions { pollInterval?: number; /** If true, automatically refetch when browser window regains focus. Defaults to true. */ 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 { @@ -47,6 +54,9 @@ export interface UsePolledDataResult { * - `"network_error"`: Network, DNS, or JSON parse failure * - `"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 * @returns Data, loading state, typed error, and refresh callback */ @@ -61,6 +71,7 @@ export function usePolledData( initialData, pollInterval, refetchOnWindowFocus = true, + pauseWhenHidden = false, } = options; const { data, loading, error, refresh } = useFetchData({ @@ -72,25 +83,61 @@ export function usePolledData( }); const refreshRef = useRef(refresh); + const intervalIdRef = useRef(null); + const previousVisibilityRef = useRef(null); + const isVisible = usePageVisibility(); useEffect(() => { refreshRef.current = 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(() => { if (!pollInterval) { 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(); }, pollInterval); + intervalIdRef.current = id; + return (): void => { - clearInterval(id); + if (intervalIdRef.current === 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 useEffect(() => {