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
21 changes: 21 additions & 0 deletions src/hooks/useSmartPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ describe("useSmartPolling", () => {
await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2));
});

it("keeps polling across re-renders when callbacks are inline (#568)", async () => {
const ref = { count: 0 };
const base = createFetchFn([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 4 }], ref);
renderHook(() =>
useSmartPolling({
fetchFn: () => base(),
compareFn: (a, b) => JSON.stringify(a) === JSON.stringify(b),
onDataChange: () => {},
interval: 1000,
}),
);

await waitFor(() => expect(base).toHaveBeenCalledTimes(1));
for (let i = 0; i < 3; i++) {
await act(async () => {
jest.advanceTimersByTime(1000);
});
}
await waitFor(() => expect(base.mock.calls.length).toBeGreaterThan(2));
});

it("applies backoff after unchangedThreshold identical results", async () => {
const ref = { count: 0 };
// Always return the same value → compareFn says "unchanged"
Expand Down
33 changes: 18 additions & 15 deletions src/hooks/useSmartPolling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,26 @@ export function useSmartPolling<T>({
const previousDataRef = useRef<T | null>(null);
const isBackgroundedRef = useRef(false);

// Keep callbacks in refs so callers passing inline functions don't change
// fetchData's identity (which would tear down the polling effect each render).
const fetchFnRef = useRef(fetchFn);
const compareFnRef = useRef(compareFn);
const onDataChangeRef = useRef(onDataChange);
fetchFnRef.current = fetchFn;
compareFnRef.current = compareFn;
onDataChangeRef.current = onDataChange;

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

try {
setIsLoading(true);
const result = await fetchFn();
const result = await fetchFnRef.current();

if (!isMountedRef.current) return;

const hasChanged = previousDataRef.current !== null
? !compareFn(previousDataRef.current, result)
? !compareFnRef.current(previousDataRef.current, result)
: true;

if (hasChanged) {
Expand All @@ -92,7 +101,7 @@ export function useSmartPolling<T>({
unchangedCountRef.current = 0;
currentIntervalRef.current = interval;
dispatch({ type: 'STOP_BACKOFF' });
onDataChange?.(result);
onDataChangeRef.current?.(result);
} else {
unchangedCountRef.current += 1;

Expand All @@ -118,7 +127,7 @@ export function useSmartPolling<T>({
setIsLoading(false);
}
}
}, [fetchFn, compareFn, interval, backoffMultiplier, maxBackoff, unchangedThreshold, onDataChange]);
}, [interval, backoffMultiplier, maxBackoff, unchangedThreshold]);

const refetch = useCallback(async () => {
currentIntervalRef.current = interval;
Expand Down Expand Up @@ -154,23 +163,17 @@ export function useSmartPolling<T>({
};
}, [enabled, fetchData]);

// Main polling effect - use a ref to track initial mount
const hasInitialized = useRef(false);

// Main polling effect: (re)creates the interval whenever it re-runs
useEffect(() => {
isMountedRef.current = true;

if (!enabled) {
return;
}

// Only start polling on initial mount or when enabled changes
if (!hasInitialized.current) {
hasInitialized.current = true;
dispatch({ type: 'START_POLLING' });
fetchData();
intervalRef.current = setInterval(fetchData, currentIntervalRef.current);
}
dispatch({ type: 'START_POLLING' });
fetchData();
intervalRef.current = setInterval(fetchData, currentIntervalRef.current);

return () => {
isMountedRef.current = false;
Expand Down