diff --git a/src/useWindowScroll.ts b/src/useWindowScroll.ts index 0145ef4afe..36ae8e1804 100644 --- a/src/useWindowScroll.ts +++ b/src/useWindowScroll.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { isBrowser, off, on } from './misc/util'; +import { isBrowser, off, on } from './misc/util'; import useRafState from './useRafState'; export interface State { @@ -9,19 +9,30 @@ export interface State { } const useWindowScroll = (): State => { - const [state, setState] = useRafState({ + const [state, setState] = useRafState(() => ({ x: isBrowser ? window.pageXOffset : 0, y: isBrowser ? window.pageYOffset : 0, - }); + })); useEffect(() => { const handler = () => { - setState({ - x: window.pageXOffset, - y: window.pageYOffset, + setState((state) => { + const { pageXOffset, pageYOffset } = window; + //Check state for change, return same state if no change happened to prevent rerender + //(see useState/setState documentation). useState/setState is used internally in useRafState/setState. + return state.x !== pageXOffset || state.y !== pageYOffset + ? { + x: pageXOffset, + y: pageYOffset, + } + : state; }); }; + //We have to update window scroll at mount, before subscription. + //Window scroll may be changed between render and effect handler. + handler(); + on(window, 'scroll', handler, { capture: false, passive: true, diff --git a/tests/useWindowScroll.test.tsx b/tests/useWindowScroll.test.tsx new file mode 100644 index 0000000000..0bece5ad00 --- /dev/null +++ b/tests/useWindowScroll.test.tsx @@ -0,0 +1,136 @@ +import React, { useEffect } from 'react'; +import { render, act as reactAct } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { replaceRaf } from 'raf-stub'; + +import useWindowScroll from '../src/useWindowScroll'; + +declare var requestAnimationFrame: { + reset: () => void; + step: (steps?: number, duration?: number) => void; +}; + +describe('useWindowScroll', () => { + beforeAll(() => { + replaceRaf(); + }); + + afterEach(() => { + requestAnimationFrame.reset(); + }); + + it('should be defined', () => { + expect(useWindowScroll).toBeDefined(); + }); + + function getHook() { + return renderHook(() => { + return useWindowScroll(); + }); + } + + function setWindowScroll(x: number, y: number) { + (window.pageXOffset as number) = x; + (window.pageYOffset as number) = y; + } + + function triggerScroll(dimension: 'x' | 'y', value: number) { + if (dimension === 'x') { + (window.pageXOffset as number) = value; + } else if (dimension === 'y') { + (window.pageYOffset as number) = value; + } + + window.dispatchEvent(new Event('scroll')); + } + + it('should return window scroll value at mount time', () => { + setWindowScroll(1, 2); + + const hook = getHook(); + + expect(hook.result.current).toEqual({ + x: 1, + y: 2, + }); + }); + + it('should re-render after X scroll change on closest RAF', () => { + setWindowScroll(1, 2); + const hook = getHook(); + + act(() => { + triggerScroll('x', 100); + expect(hook.result.current.x).toBe(1); + + requestAnimationFrame.step(); + }); + + expect(hook.result.current.x).toBe(100); + + act(() => { + triggerScroll('x', 1000); + expect(hook.result.current.x).toBe(100); + requestAnimationFrame.step(); + }); + + expect(hook.result.current.x).toBe(1000); + }); + + it('should re-render after Y scroll change on closest RAF', () => { + setWindowScroll(1, 2); + const hook = getHook(); + + act(() => { + triggerScroll('y', 200); + expect(hook.result.current.y).toBe(2); + requestAnimationFrame.step(); + }); + + expect(hook.result.current.y).toBe(200); + + act(() => { + triggerScroll('y', 300); + expect(hook.result.current.y).toBe(200); + requestAnimationFrame.step(); + }); + + expect(hook.result.current.y).toBe(300); + }); + + it('should set window scroll in mount effect, just before subscription, to prevent losing scroll change between render and mount', () => { + const initialScroll = { x: 1, y: 2 }; + const afterRenderScroll = { x: 2, y: 3 }; + const result = { + x: 0, y: 0 + }; + + setWindowScroll(initialScroll.x, initialScroll.y); + + const TestComponent = () => { + useEffect(() => { + // Simulate window scroll changing between component render and useWindowScroll effect handler, + // before adding the event listener + setWindowScroll(afterRenderScroll.x, afterRenderScroll.y); + }, []); + + const { x, y } = useWindowScroll(); + result.x = x; + result.y = y; + return
; + }; + + const { rerender } = render(); + rerender(); + + //result update is delayed by requestAnimationFrame + expect(result).toEqual(initialScroll); + + reactAct(() => { + requestAnimationFrame.step(); + }); + + //result is updated next requestAnimationFrame + expect(result).toEqual(afterRenderScroll); + }); +});