Skip to content

Commit f1958ae

Browse files
author
Tobias Lengsholz
committed
feat: store scroll position for page with the same location
This will mean that even when not using back/forward buttons, the scroll position will be restored as long as it is the same url with the same parameters.
1 parent a71adb4 commit f1958ae

File tree

6 files changed

+172
-8
lines changed

6 files changed

+172
-8
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ Default: true
114114

115115
True to set Next.js Link default `scroll` property to `false`, false otherwise. Since the goal of this package is to manually control the scroll, you don't want Next.js default behavior of scrolling to top when clicking links.
116116

117+
#### restoreSameLocation?
118+
119+
Type: `boolean`
120+
Default: false
121+
122+
True to enable scroll restoration when the same location is navigated. By default, only going backwards and forward in the browser history will cause the scroll position to be restored.
123+
117124
#### children
118125

119126
Type: `ReactNode`

src/RouterScrollProvider.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,31 +27,46 @@ const useDisableNextLinkScroll = (disableNextLinkScroll) => {
2727
}, [disableNextLinkScroll]);
2828
};
2929

30-
const useScrollBehavior = (shouldUpdateScroll) => {
30+
const useScrollBehavior = (shouldUpdateScroll, restoreSameLocation) => {
3131
// Create NextScrollBehavior instance once.
3232
const shouldUpdateScrollRef = useRef();
3333
const scrollBehaviorRef = useRef();
34+
const mounted = useRef(false);
3435

3536
shouldUpdateScrollRef.current = shouldUpdateScroll;
3637

38+
useEffect(() => {
39+
if (scrollBehaviorRef.current) {
40+
scrollBehaviorRef.current.setRestoreSameLocation(restoreSameLocation);
41+
}
42+
}, [restoreSameLocation]);
43+
3744
if (!scrollBehaviorRef.current) {
3845
scrollBehaviorRef.current = new NextScrollBehavior(
3946
(...args) => shouldUpdateScrollRef.current(...args),
47+
restoreSameLocation,
4048
);
4149
}
4250

43-
// Destroy NextScrollBehavior instance when unmonting.
44-
useEffect(() => () => scrollBehaviorRef.current.stop(), []);
51+
// Destroy NextScrollBehavior instance when unmounting.
52+
useEffect(() => {
53+
mounted.current = true;
54+
55+
return () => {
56+
mounted.current = false;
57+
scrollBehaviorRef.current?.stop();
58+
};
59+
}, []);
4560

4661
return scrollBehaviorRef.current;
4762
};
4863

49-
const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, children }) => {
64+
const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, restoreSameLocation, children }) => {
5065
// Disable next <Link> scroll or not.
5166
useDisableNextLinkScroll(disableNextLinkScroll);
5267

5368
// Get the scroll behavior, creating it just once.
54-
const scrollBehavior = useScrollBehavior(shouldUpdateScroll);
69+
const scrollBehavior = useScrollBehavior(shouldUpdateScroll, restoreSameLocation);
5570

5671
// Create facade to use as the provider value.
5772
const providerValue = useMemo(() => ({
@@ -75,6 +90,7 @@ ScrollBehaviorProvider.defaultProps = {
7590
ScrollBehaviorProvider.propTypes = {
7691
disableNextLinkScroll: PropTypes.bool,
7792
shouldUpdateScroll: PropTypes.func,
93+
restoreSameLocation: PropTypes.bool,
7894
children: PropTypes.node,
7995
};
8096

src/RouterScrollProvider.test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import RouterScrollContext from './context';
55
import RouterScrollProvider from './RouterScrollProvider';
66

77
let mockNextScrollBehavior;
8+
let mockStateStorage;
89

910
jest.mock('./scroll-behavior', () => {
1011
const NextScrollBehavior = jest.requireActual('./scroll-behavior');
@@ -25,6 +26,23 @@ jest.mock('./scroll-behavior', () => {
2526
return SpiedNextScrollBehavior;
2627
});
2728

29+
jest.mock('./scroll-behavior/StateStorage', () => {
30+
const StateStorage = jest.requireActual('./scroll-behavior/StateStorage');
31+
32+
class SpiedStateStorage extends StateStorage {
33+
constructor(...args) {
34+
super(...args);
35+
36+
mockStateStorage = this; // eslint-disable-line consistent-this
37+
38+
jest.spyOn(this, 'save');
39+
jest.spyOn(this, 'read');
40+
}
41+
}
42+
43+
return SpiedStateStorage;
44+
});
45+
2846
afterEach(() => {
2947
mockNextScrollBehavior = undefined;
3048
});
@@ -173,3 +191,51 @@ it('should allow changing shouldUpdateScroll', () => {
173191
expect(shouldUpdateScroll1).toHaveBeenCalledTimes(1);
174192
expect(shouldUpdateScroll2).toHaveBeenCalledTimes(1);
175193
});
194+
195+
it('allows setting restoreSameLocation', () => {
196+
const MyComponent = () => {
197+
useContext(RouterScrollContext);
198+
199+
return null;
200+
};
201+
202+
render(
203+
<RouterScrollProvider>
204+
<MyComponent />
205+
</RouterScrollProvider>,
206+
);
207+
208+
expect(mockStateStorage.restoreSameLocation).toBe(false);
209+
210+
render(
211+
<RouterScrollProvider restoreSameLocation>
212+
<MyComponent />
213+
</RouterScrollProvider>,
214+
);
215+
216+
expect(mockStateStorage.restoreSameLocation).toBe(true);
217+
});
218+
219+
it('allows changing restoreSameLocation', () => {
220+
const MyComponent = () => {
221+
useContext(RouterScrollContext);
222+
223+
return null;
224+
};
225+
226+
const { rerender } = render(
227+
<RouterScrollProvider>
228+
<MyComponent />
229+
</RouterScrollProvider>,
230+
);
231+
232+
expect(mockStateStorage.restoreSameLocation).toBe(false);
233+
234+
rerender(
235+
<RouterScrollProvider restoreSameLocation>
236+
<MyComponent />
237+
</RouterScrollProvider>,
238+
);
239+
240+
expect(mockStateStorage.restoreSameLocation).toBe(true);
241+
});

src/scroll-behavior/NextScrollBehavior.browser.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
1212
_context;
1313
_prevContext;
1414
_debounceSavePositionMap = new Map();
15+
_stateStorage;
1516

16-
constructor(shouldUpdateScroll) {
17+
constructor(shouldUpdateScroll, restoreSameLocation = false) {
1718
setupRouter();
19+
const stateStorage = new StateStorage({ restoreSameLocation });
1820

1921
super({
2022
addNavigationListener: (callback) => {
@@ -37,10 +39,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
3739
};
3840
},
3941
getCurrentLocation: () => this._context.location,
40-
stateStorage: new StateStorage(),
42+
stateStorage,
4143
shouldUpdateScroll,
4244
});
4345

46+
this._stateStorage = stateStorage;
4447
this._context = this._createContext();
4548
this._prevContext = null;
4649

@@ -64,6 +67,10 @@ export default class NextScrollBehavior extends ScrollBehavior {
6467
super.updateScroll(prevContext, context);
6568
}
6669

70+
setRestoreSameLocation(newValue = false) {
71+
this._stateStorage.restoreSameLocation = newValue;
72+
}
73+
6774
stop() {
6875
super.stop();
6976

src/scroll-behavior/NextScrollBehavior.browser.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ describe('constructor()', () => {
7373
expect(scrollBehavior._shouldUpdateScroll).toBe(shouldUpdateScroll);
7474
});
7575

76+
it('should forward restoreSameLocation to StateStorage', () => {
77+
scrollBehavior = new NextScrollBehavior(() => {}, true);
78+
79+
expect(mockStateStorage.restoreSameLocation).toBe(true);
80+
});
81+
7682
it('should set history.scrollRestoration to manual, even on Safari iOS', () => {
7783
// eslint-disable-next-line max-len
7884
navigator.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/605.1';
@@ -377,3 +383,55 @@ it('should update scroll correctly based on history changes', async () => {
377383

378384
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
379385
});
386+
387+
it('should restore scroll position if same url is opened', async () => {
388+
scrollBehavior = new NextScrollBehavior(undefined, true);
389+
390+
jest.spyOn(scrollBehavior, 'scrollToTarget');
391+
Object.defineProperty(scrollBehavior, '_numWindowScrollAttempts', {
392+
get: () => 1000,
393+
set: () => {},
394+
});
395+
396+
// First page
397+
history.replaceState({ as: '/' }, '', '/');
398+
Router.events.emit('routeChangeComplete', '/');
399+
window.pageYOffset = 0;
400+
scrollBehavior.updateScroll();
401+
402+
await sleep(10);
403+
404+
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(1, window, [0, 0]);
405+
406+
// Navigate to new page & scroll
407+
history.pushState({ as: '/page2' }, '', '/page2');
408+
Router.events.emit('routeChangeComplete', '/');
409+
window.pageYOffset = 123;
410+
window.dispatchEvent(new CustomEvent('scroll'));
411+
412+
await sleep(200);
413+
414+
scrollBehavior.updateScroll();
415+
416+
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(2, window, [0, 123]);
417+
418+
// Go to previous page
419+
history.pushState({ as: '/' }, '', '/');
420+
Router.events.emit('routeChangeComplete', '/');
421+
await sleep(10);
422+
423+
location.key = history.state.locationKey;
424+
scrollBehavior.updateScroll();
425+
426+
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(3, window, [0, 0]);
427+
428+
// Go to next page
429+
history.pushState({ as: '/page2' }, '', '/page2');
430+
Router.events.emit('routeChangeComplete', '/');
431+
await sleep(10);
432+
433+
location.key = history.state.locationKey;
434+
scrollBehavior.updateScroll();
435+
436+
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
437+
});

src/scroll-behavior/StateStorage.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
/* istanbul ignore file */
22
import { readState, saveState } from 'history/lib/DOMStateStorage';
3+
import md5 from 'md5';
34

45
const STATE_KEY_PREFIX = '@@scroll|';
56

7+
const hashLocation = (location) => md5(`${location.host}${location.pathname}${location.hash}${location.search}`);
8+
69
export default class StateStorage {
10+
restoreSameLocation;
11+
12+
constructor({ restoreSameLocation }) {
13+
this.restoreSameLocation = restoreSameLocation || false;
14+
}
15+
716
read(location, key) {
817
return readState(this.getStateKey(location, key));
918
}
@@ -13,7 +22,8 @@ export default class StateStorage {
1322
}
1423

1524
getStateKey(location, key) {
16-
const locationKey = location.key ?? '_default';
25+
const locationKey = this.restoreSameLocation ? hashLocation(location) : (location.key ?? '_default');
26+
1727
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
1828

1929
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;

0 commit comments

Comments
 (0)