Refactor usePolledData hook and add comprehensive tests

- Renamed usePolledIntervalCheck to usePolledData for clarity
- Updated hook to properly manage interval cleanup on unmount
- Added comprehensive test suite covering normal operation, error handling, and cleanup
- Updated documentation to reflect new hook name
- Updated Tasks.md to track progress

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-30 20:24:47 +02:00
parent 69d32bfbe9
commit 3bd2a71367
4 changed files with 280 additions and 61 deletions

View File

@@ -4,7 +4,7 @@
* Composes useFetchData and adds polling and window-focus refetch semantics
* for non-list endpoints that need periodic updates.
*/
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useFetchData } from "./useFetchData";
import { usePageVisibility } from "./usePageVisibility";
import type { FetchError } from "../types/api";
@@ -57,6 +57,9 @@ export interface UsePolledDataResult<TData> {
* 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
*/
@@ -82,40 +85,87 @@ export function usePolledData<TResponse, TData>(
initialData,
});
const refreshRef = useRef(refresh);
const intervalIdRef = useRef<number | null>(null);
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]);
// Polling effect: set up interval if pollInterval is provided and page is visible
/**
* 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;
}
// 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;
}
// 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;
}
const id = window.setInterval((): void => {
refreshRef.current();
}, pollInterval);
cancelledRef.current = false;
intervalIdRef.current = id;
// 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 => {
if (intervalIdRef.current === id) {
clearInterval(id);
intervalIdRef.current = null;
cancelledRef.current = true;
if (timeoutIdRef.current !== null) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
};
}, [pollInterval, pauseWhenHidden, isVisible]);
@@ -128,12 +178,13 @@ export function usePolledData<TResponse, TData>(
// 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;
// Page became visible: clear pending timeout and refresh immediately
if (timeoutIdRef.current !== null) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
refreshRef.current();
pollStartTimeRef.current = performance.now();
refreshRef.current?.();
}
previousVisibilityRef.current = isVisible;