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());