Skip to content

Commit 6b708c8

Browse files
fix for useWindowScroll may lose window scroll change at mount #1699
Update tests/useWindowScroll.test.tsx Co-authored-by: Mathias <mathiassoeholm@gmail.com> fix for useWindowScroll may lose window scroll change at mount #1699, fixes for review by mathiassoeholm Update tests/useWindowScroll.test.tsx Co-authored-by: Mathias <mathiassoeholm@gmail.com>
1 parent 6ee97ec commit 6b708c8

2 files changed

Lines changed: 153 additions & 6 deletions

File tree

src/useWindowScroll.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
2-
import { isBrowser, off, on } from './misc/util';
32

3+
import { isBrowser, off, on } from './misc/util';
44
import useRafState from './useRafState';
55

66
export interface State {
@@ -9,19 +9,30 @@ export interface State {
99
}
1010

1111
const useWindowScroll = (): State => {
12-
const [state, setState] = useRafState<State>({
12+
const [state, setState] = useRafState<State>(() => ({
1313
x: isBrowser ? window.pageXOffset : 0,
1414
y: isBrowser ? window.pageYOffset : 0,
15-
});
15+
}));
1616

1717
useEffect(() => {
1818
const handler = () => {
19-
setState({
20-
x: window.pageXOffset,
21-
y: window.pageYOffset,
19+
setState((state) => {
20+
const { pageXOffset, pageYOffset } = window;
21+
//Check state for change, return same state if no change happened to prevent rerender
22+
//(see useState/setState documentation). useState/setState is used internally in useRafState/setState.
23+
return state.x !== pageXOffset || state.y !== pageYOffset
24+
? {
25+
x: pageXOffset,
26+
y: pageYOffset,
27+
}
28+
: state;
2229
});
2330
};
2431

32+
//We have to update window scroll at mount, before subscription.
33+
//Window scroll may be changed between render and effect handler.
34+
handler();
35+
2536
on(window, 'scroll', handler, {
2637
capture: false,
2738
passive: true,

tests/useWindowScroll.test.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React, { useEffect } from 'react';
2+
import { render, act as reactAct } from '@testing-library/react';
3+
import { act, renderHook } from '@testing-library/react-hooks';
4+
import { replaceRaf } from 'raf-stub';
5+
6+
import useWindowScroll from '../src/useWindowScroll';
7+
8+
declare var requestAnimationFrame: {
9+
reset: () => void;
10+
step: (steps?: number, duration?: number) => void;
11+
};
12+
13+
describe('useWindowScroll', () => {
14+
beforeAll(() => {
15+
replaceRaf();
16+
});
17+
18+
afterEach(() => {
19+
requestAnimationFrame.reset();
20+
});
21+
22+
it('should be defined', () => {
23+
expect(useWindowScroll).toBeDefined();
24+
});
25+
26+
function getHook() {
27+
return renderHook(() => {
28+
return useWindowScroll();
29+
});
30+
}
31+
32+
function setWindowScroll(x: number, y: number) {
33+
(window.pageXOffset as number) = x;
34+
(window.pageYOffset as number) = y;
35+
}
36+
37+
function triggerScroll(dimension: 'x' | 'y', value: number) {
38+
if (dimension === 'x') {
39+
(window.pageXOffset as number) = value;
40+
} else if (dimension === 'y') {
41+
(window.pageYOffset as number) = value;
42+
}
43+
44+
window.dispatchEvent(new Event('scroll'));
45+
}
46+
47+
it('should return window scroll value at mount time', () => {
48+
setWindowScroll(1, 2);
49+
50+
const hook = getHook();
51+
52+
expect(hook.result.current).toEqual({
53+
x: 1,
54+
y: 2,
55+
});
56+
});
57+
58+
it('should re-render after X scroll change on closest RAF', () => {
59+
setWindowScroll(1, 2);
60+
const hook = getHook();
61+
62+
act(() => {
63+
triggerScroll('x', 100);
64+
expect(hook.result.current.x).toBe(1);
65+
66+
requestAnimationFrame.step();
67+
});
68+
69+
expect(hook.result.current.x).toBe(100);
70+
71+
act(() => {
72+
triggerScroll('x', 1000);
73+
expect(hook.result.current.x).toBe(100);
74+
requestAnimationFrame.step();
75+
});
76+
77+
expect(hook.result.current.x).toBe(1000);
78+
});
79+
80+
it('should re-render after Y scroll change on closest RAF', () => {
81+
setWindowScroll(1, 2);
82+
const hook = getHook();
83+
84+
act(() => {
85+
triggerScroll('y', 200);
86+
expect(hook.result.current.y).toBe(2);
87+
requestAnimationFrame.step();
88+
});
89+
90+
expect(hook.result.current.y).toBe(200);
91+
92+
act(() => {
93+
triggerScroll('y', 300);
94+
expect(hook.result.current.y).toBe(200);
95+
requestAnimationFrame.step();
96+
});
97+
98+
expect(hook.result.current.y).toBe(300);
99+
});
100+
101+
it('should set window scroll in mount effect, just before subscription, to prevent losing scroll change between render and mount', () => {
102+
const initialScroll = { x: 1, y: 2 };
103+
const afterRenderScroll = { x: 2, y: 3 };
104+
const result = {
105+
x: 0, y: 0
106+
};
107+
108+
setWindowScroll(initialScroll.x, initialScroll.y);
109+
110+
const TestComponent = () => {
111+
useEffect(() => {
112+
// Simulate window scroll changing between component render and useWindowScroll effect handler,
113+
// before adding the event listener
114+
setWindowScroll(afterRenderScroll.x, afterRenderScroll.y);
115+
}, []);
116+
117+
const { x, y } = useWindowScroll();
118+
result.x = x;
119+
result.y = y;
120+
return <div />;
121+
};
122+
123+
const { rerender } = render(<TestComponent />);
124+
rerender(<TestComponent />);
125+
126+
//result update is delayed by requestAnimationFrame
127+
expect(result).toEqual(initialScroll);
128+
129+
reactAct(() => {
130+
requestAnimationFrame.step();
131+
});
132+
133+
//result is updated next requestAnimationFrame
134+
expect(result).toEqual(afterRenderScroll);
135+
});
136+
});

0 commit comments

Comments
 (0)