From c64092c60069079dde0b17f745c2f5cfcdf2a95e Mon Sep 17 00:00:00 2001 From: Rodion Steshenko Date: Mon, 16 Feb 2026 22:04:53 -0500 Subject: [PATCH] fix: prevent setTimeout overflow for large ms values in useRaf (#779) setTimeout fires immediately when delay exceeds 2^31-1 (2147483647ms). The default ms=1e12 caused the stop timer to fire instantly, breaking the hook. Skip the stop timer for durations exceeding the safe limit. Also fixes the delay test which was inadvertently passing due to the same overflow bug. --- src/useRaf.ts | 14 ++++++++++---- tests/useRaf.test.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/useRaf.ts b/src/useRaf.ts index 7ad5ef2613..6cf6862ca8 100644 --- a/src/useRaf.ts +++ b/src/useRaf.ts @@ -1,6 +1,10 @@ import { useState } from 'react'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; +// setTimeout max delay is a 32-bit signed int (2147483647ms ~24.8 days). +// Values above this overflow and fire immediately. See: https://github.com/streamich/react-use/issues/779 +const MAX_SAFE_TIMEOUT = 2147483647; + const useRaf = (ms: number = 1e12, delay: number = 0): number => { const [elapsed, set] = useState(0); @@ -18,10 +22,12 @@ const useRaf = (ms: number = 1e12, delay: number = 0): number => { raf = requestAnimationFrame(onFrame); }; const onStart = () => { - timerStop = setTimeout(() => { - cancelAnimationFrame(raf); - set(1); - }, ms); + if (ms <= MAX_SAFE_TIMEOUT) { + timerStop = setTimeout(() => { + cancelAnimationFrame(raf); + set(1); + }, ms); + } start = Date.now(); loop(); }; diff --git a/tests/useRaf.test.ts b/tests/useRaf.test.ts index 52f86b18df..7f9f09653e 100644 --- a/tests/useRaf.test.ts +++ b/tests/useRaf.test.ts @@ -121,7 +121,8 @@ it('should return always 1 after corresponding ms reached', () => { }); it('should wait until delay reached to start calculating elapsed percentage', () => { - const { result } = renderHook(() => useRaf(undefined, 500)); + const customMs = 2000; + const { result } = renderHook(() => useRaf(customMs, 500)); expect(result.current).toBe(0); @@ -137,10 +138,38 @@ it('should wait until delay reached to start calculating elapsed percentage', () act(() => { jest.advanceTimersByTime(1); // fast-forward exactly to custom delay + // After delay is reached, onStart fires and begins the rAF loop. + // Step one animation frame to see elapsed progress. + spyDateNow.mockImplementationOnce(() => fixedStart + customMs * 0.5); + requestAnimationFrame.step(); }); expect(result.current).not.toBe(0); }); +it('should not immediately complete when ms exceeds setTimeout max (issue #779)', () => { + // setTimeout fires immediately if delay > 2^31-1 (2147483647ms). + // With the default ms=1e12, the stop timer must NOT fire immediately. + const { result } = renderHook(() => useRaf()); + + // After starting (run the delay timer), elapsed should still be 0 + // because no animation frames have run yet. + act(() => { + jest.runOnlyPendingTimers(); // start after delay=0 + }); + + // If the bug is present, the stop setTimeout(cb, 1e12) fires immediately + // and sets elapsed to 1. With the fix, it should still be 0. + expect(result.current).toBe(0); + + // Stepping one frame with a small time elapsed should give a tiny fraction, not 1 + act(() => { + spyDateNow.mockImplementationOnce(() => fixedStart + 100); + requestAnimationFrame.step(); + }); + expect(result.current).toBeGreaterThan(0); + expect(result.current).toBeLessThan(1); +}); + it('should clear pending timers on unmount', () => { const spyRafStop = jest.spyOn(global, 'cancelAnimationFrame' as any); const { unmount } = renderHook(() => useRaf());