Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/hooks/useSmartPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,50 @@ describe("useSmartPolling", () => {
expect(result.current.isBackingOff).toBe(true);
});

it("actually slows the timer on backoff (capped at maxBackoff) and resets on refetch", async () => {
const fetchFn = jest.fn(async () => ({ n: 1 }));
const { result } = renderHook(() =>
useSmartPolling({
fetchFn,
interval: 1000,
unchangedThreshold: 2,
backoffMultiplier: 2,
maxBackoff: 4000,
}),
);
const advance = async (ms: number) => {
await act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
};

await advance(0);
expect(fetchFn).toHaveBeenCalledTimes(1);
await advance(1000); // call 2 (unchanged 1)
await advance(1000); // call 3 (unchanged 2) → interval 2000
expect(fetchFn).toHaveBeenCalledTimes(3);
expect(result.current.isBackingOff).toBe(true);

await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(3); // no call at the old 1s cadence
await advance(1000); // call 4 → interval 4000 (max)
expect(fetchFn).toHaveBeenCalledTimes(4);

await advance(3000);
expect(fetchFn).toHaveBeenCalledTimes(4);
await advance(1000); // call 5, still capped at 4000
expect(fetchFn).toHaveBeenCalledTimes(5);

// refetch resets to the base interval
await act(async () => {
await result.current.refetch();
});
expect(fetchFn).toHaveBeenCalledTimes(6);
expect(result.current.isBackingOff).toBe(false);
await advance(1000);
expect(fetchFn).toHaveBeenCalledTimes(7);
});

it("does not poll when enabled is false", async () => {
const ref = { count: 0 };
const fetchFn = createFetchFn([{ n: 1 }], ref);
Expand Down
30 changes: 26 additions & 4 deletions src/hooks/useSmartPolling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ export function useSmartPolling<T>({
const previousDataRef = useRef<T | null>(null);
const isBackgroundedRef = useRef(false);

const fetchDataRef = useRef<() => Promise<void>>(async () => {});

// Restart the running timer so a changed currentIntervalRef takes effect.
// No-op when polling isn't active (no timer running).
const rescheduleInterval = useCallback(() => {
if (!intervalRef.current) return;
clearInterval(intervalRef.current);
intervalRef.current = setInterval(
() => fetchDataRef.current(),
currentIntervalRef.current
);
}, []);

const fetchData = useCallback(async () => {
if (!isMountedRef.current) return;

Expand All @@ -90,7 +103,10 @@ export function useSmartPolling<T>({
setData(result);
previousDataRef.current = result;
unchangedCountRef.current = 0;
currentIntervalRef.current = interval;
if (currentIntervalRef.current !== interval) {
currentIntervalRef.current = interval;
rescheduleInterval();
}
dispatch({ type: 'STOP_BACKOFF' });
onDataChange?.(result);
} else {
Expand All @@ -103,6 +119,7 @@ export function useSmartPolling<T>({
);
if (newInterval > currentIntervalRef.current) {
currentIntervalRef.current = newInterval;
rescheduleInterval();
dispatch({ type: 'START_BACKOFF' });
}
}
Expand All @@ -118,14 +135,19 @@ export function useSmartPolling<T>({
setIsLoading(false);
}
}
}, [fetchFn, compareFn, interval, backoffMultiplier, maxBackoff, unchangedThreshold, onDataChange]);
}, [fetchFn, compareFn, interval, backoffMultiplier, maxBackoff, unchangedThreshold, onDataChange, rescheduleInterval]);

fetchDataRef.current = fetchData;

const refetch = useCallback(async () => {
currentIntervalRef.current = interval;
if (currentIntervalRef.current !== interval) {
currentIntervalRef.current = interval;
rescheduleInterval();
}
dispatch({ type: 'STOP_BACKOFF' });
unchangedCountRef.current = 0;
await fetchData();
}, [fetchData, interval]);
}, [fetchData, interval, rescheduleInterval]);

// Handle visibility change
useEffect(() => {
Expand Down