- Simplify useFetchData: remove unused URL building logic - Add usePolledData initial implementation - Add router page_size param validation test - Update API reference docs - Clean up tasks doc
209 lines
7.0 KiB
TypeScript
209 lines
7.0 KiB
TypeScript
/**
|
|
* 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<TResponse, TData> {
|
|
/** Async function that accepts an AbortSignal for cancellation. */
|
|
fetcher: (signal: AbortSignal) => Promise<TResponse>;
|
|
/** 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<TData> {
|
|
/** 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<TResponse, TData>(
|
|
options: UsePolledDataOptions<TResponse, TData>,
|
|
): UsePolledDataResult<TData> {
|
|
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<number | null>(null);
|
|
const pollStartTimeRef = useRef<number | null>(null);
|
|
const previousVisibilityRef = useRef<boolean | null>(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 };
|
|
}
|