/** * Generic hook for loading and polling single-item data from an API endpoint. * * Composes useFetchData and adds polling and window-focus refetch semantics * for non-list endpoints that need periodic updates. */ import { useCallback, useEffect, useRef } from "react"; import { useFetchData } from "./useFetchData"; import { usePageVisibility } from "./usePageVisibility"; import type { FetchError } from "../types/api"; export interface UsePolledDataOptions { /** Async function that accepts an AbortSignal for cancellation. */ fetcher: (signal: AbortSignal) => Promise; /** Synchronous selector to extract data from response. */ selector: (response: TResponse) => TData; /** Human-readable error message used as fallback if fetch fails. */ errorMessage: string; /** Optional callback invoked after successful fetch with full response. */ onSuccess?: (response: TResponse) => void; /** Initial data value. Defaults to null. */ initialData?: TData; /** Polling interval in milliseconds. If unset, no periodic polling. */ 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 { /** The extracted data from the most recent successful fetch, or null if not yet fetched. */ data: TData | null; /** True while a fetch is in-flight, false when complete. */ loading: boolean; /** Typed error or null. Check `error?.type` to handle specific failure modes. */ error: FetchError | null; /** Trigger a fresh fetch. Cancels any in-flight request first. */ refresh: () => void; } /** * Load a single-item response and expose refresh semantics with polling support. * * Provides typed error handling through the `error` property, which is either * `null` or a discriminated `FetchError` union. Use the error's `type` field * to determine how to handle it: * * - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x) * - `"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. * * Polling uses drift correction: if a fetch takes longer than expected, the next poll * is scheduled to compensate, maintaining the desired polling interval. * * @param options - Configuration options * @returns Data, loading state, typed error, and refresh callback */ export function usePolledData( options: UsePolledDataOptions, ): UsePolledDataResult { const { fetcher, selector, errorMessage, onSuccess, initialData, pollInterval, refetchOnWindowFocus = true, pauseWhenHidden = false, } = options; const { data, loading, error, refresh } = useFetchData({ fetcher, selector, errorMessage, onSuccess, initialData, }); const refreshRef = useRef<(() => void) | null>(null); const timeoutIdRef = useRef(null); const pollStartTimeRef = useRef(null); const previousVisibilityRef = useRef(null); const isVisible = usePageVisibility(); const cancelledRef = useRef(false); const previousLoadingRef = useRef(true); useEffect(() => { refreshRef.current = refresh; }, [refresh]); /** * Schedule next poll with drift correction. * Calculates elapsed time since poll start and compensates the delay * to maintain the desired polling interval despite fetch duration variance. */ const scheduleNextPoll = useCallback((nextDelay: number): void => { if (cancelledRef.current) return; if (timeoutIdRef.current !== null) { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } const id = window.setTimeout((): void => { if (cancelledRef.current) return; pollStartTimeRef.current = performance.now(); if (refreshRef.current) { refreshRef.current(); } }, nextDelay); timeoutIdRef.current = id; }, []); // Monitor fetch completion and schedule next poll with drift correction useEffect(() => { if (!pollInterval) { return; } // Detect transition from loading=true to loading=false (fetch completed) if (previousLoadingRef.current && !loading && pollStartTimeRef.current !== null) { // Fetch completed, calculate elapsed time and schedule next poll const elapsed = performance.now() - pollStartTimeRef.current; const nextDelay = Math.max(0, pollInterval - elapsed); scheduleNextPoll(nextDelay); } previousLoadingRef.current = loading; }, [loading, pollInterval, scheduleNextPoll]); // Polling effect: set up self-scheduling polling if pollInterval is provided and page is visible useEffect(() => { if (!pollInterval) { return; } cancelledRef.current = false; // If pauseWhenHidden is enabled and page is hidden, don't start polling if (pauseWhenHidden && !isVisible) { return; } // Record when polling starts and schedule first poll immediately pollStartTimeRef.current = performance.now(); const id = window.setTimeout((): void => { if (cancelledRef.current) return; pollStartTimeRef.current = performance.now(); refreshRef.current?.(); }, 0); timeoutIdRef.current = id; return (): void => { cancelledRef.current = true; if (timeoutIdRef.current !== null) { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } }; }, [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 timeout and refresh immediately if (timeoutIdRef.current !== null) { clearTimeout(timeoutIdRef.current); timeoutIdRef.current = null; } pollStartTimeRef.current = performance.now(); refreshRef.current?.(); } previousVisibilityRef.current = isVisible; }, [isVisible, pauseWhenHidden, pollInterval]); // Window focus: optional refetch on regain focus useEffect(() => { if (!refetchOnWindowFocus) return; const onFocus = (): void => { refreshRef.current?.(); }; window.addEventListener("focus", onFocus); return (): void => { window.removeEventListener("focus", onFocus); }; }, [refetchOnWindowFocus]); return { data: data ?? null, loading, error, refresh }; }