diff --git a/README.md b/README.md
index 625d03a..d12793c 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,13 @@ Default: true
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.
+#### restoreSameLocation?
+
+Type: `boolean`
+Default: false
+
+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.
+
#### children
Type: `ReactNode`
diff --git a/package-lock.json b/package-lock.json
index 12efe1b..d08cc4a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,12 +6,13 @@
"packages": {
"": {
"name": "@moxy/next-router-scroll",
- "version": "2.1.1",
+ "version": "2.2.0",
"license": "MIT",
"dependencies": {
"history": "^3.0.0",
"hoist-non-react-statics": "^3.3.2",
"lodash": "^4.17.21",
+ "md5": "^2.3.0",
"prop-types": "^15.7.2",
"scroll-behavior": "^0.11.0"
},
@@ -5412,6 +5413,14 @@
"node": ">=10"
}
},
+ "node_modules/charenc": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+ "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@@ -6264,6 +6273,14 @@
"node": ">= 8"
}
},
+ "node_modules/crypt": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+ "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/crypto-browserify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@@ -9879,8 +9896,7 @@
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
- "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
- "dev": true
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/is-callable": {
"version": "1.2.3",
@@ -13900,6 +13916,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/md5": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+ "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+ "dependencies": {
+ "charenc": "0.0.2",
+ "crypt": "0.0.2",
+ "is-buffer": "~1.1.6"
+ }
+ },
"node_modules/md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -25252,6 +25278,11 @@
"integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
"dev": true
},
+ "charenc": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+ "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc="
+ },
"chokidar": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
@@ -25940,6 +25971,11 @@
"which": "^2.0.1"
}
},
+ "crypt": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+ "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs="
+ },
"crypto-browserify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@@ -28726,8 +28762,7 @@
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
- "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
- "dev": true
+ "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"is-callable": {
"version": "1.2.3",
@@ -31755,6 +31790,16 @@
"object-visit": "^1.0.0"
}
},
+ "md5": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+ "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+ "requires": {
+ "charenc": "0.0.2",
+ "crypt": "0.0.2",
+ "is-buffer": "~1.1.6"
+ }
+ },
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
diff --git a/package.json b/package.json
index 68d35a4..1a5b7c7 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"history": "^3.0.0",
"hoist-non-react-statics": "^3.3.2",
"lodash": "^4.17.21",
+ "md5": "^2.3.0",
"prop-types": "^15.7.2",
"scroll-behavior": "^0.11.0"
},
diff --git a/src/RouterScrollProvider.js b/src/RouterScrollProvider.js
index f7244bf..4413b0d 100644
--- a/src/RouterScrollProvider.js
+++ b/src/RouterScrollProvider.js
@@ -27,31 +27,38 @@ const useDisableNextLinkScroll = (disableNextLinkScroll) => {
}, [disableNextLinkScroll]);
};
-const useScrollBehavior = (shouldUpdateScroll) => {
+const useScrollBehavior = (shouldUpdateScroll, restoreSameLocation) => {
// Create NextScrollBehavior instance once.
const shouldUpdateScrollRef = useRef();
const scrollBehaviorRef = useRef();
shouldUpdateScrollRef.current = shouldUpdateScroll;
+ useEffect(() => {
+ if (scrollBehaviorRef.current) {
+ scrollBehaviorRef.current.setRestoreSameLocation(restoreSameLocation);
+ }
+ }, [restoreSameLocation]);
+
if (!scrollBehaviorRef.current) {
scrollBehaviorRef.current = new NextScrollBehavior(
(...args) => shouldUpdateScrollRef.current(...args),
+ restoreSameLocation,
);
}
- // Destroy NextScrollBehavior instance when unmonting.
- useEffect(() => () => scrollBehaviorRef.current.stop(), []);
+ // Destroy NextScrollBehavior instance when unmounting.
+ useEffect(() => () => scrollBehaviorRef.current?.stop(), []);
return scrollBehaviorRef.current;
};
-const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, children }) => {
+const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, restoreSameLocation, children }) => {
// Disable next scroll or not.
useDisableNextLinkScroll(disableNextLinkScroll);
// Get the scroll behavior, creating it just once.
- const scrollBehavior = useScrollBehavior(shouldUpdateScroll);
+ const scrollBehavior = useScrollBehavior(shouldUpdateScroll, restoreSameLocation);
// Create facade to use as the provider value.
const providerValue = useMemo(() => ({
@@ -75,6 +82,7 @@ ScrollBehaviorProvider.defaultProps = {
ScrollBehaviorProvider.propTypes = {
disableNextLinkScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
+ restoreSameLocation: PropTypes.bool,
children: PropTypes.node,
};
diff --git a/src/RouterScrollProvider.test.js b/src/RouterScrollProvider.test.js
index 1c9ba71..97a5fd7 100644
--- a/src/RouterScrollProvider.test.js
+++ b/src/RouterScrollProvider.test.js
@@ -5,6 +5,7 @@ import RouterScrollContext from './context';
import RouterScrollProvider from './RouterScrollProvider';
let mockNextScrollBehavior;
+let mockStateStorage;
jest.mock('./scroll-behavior', () => {
const NextScrollBehavior = jest.requireActual('./scroll-behavior');
@@ -25,6 +26,23 @@ jest.mock('./scroll-behavior', () => {
return SpiedNextScrollBehavior;
});
+jest.mock('./scroll-behavior/StateStorage', () => {
+ const StateStorage = jest.requireActual('./scroll-behavior/StateStorage');
+
+ class SpiedStateStorage extends StateStorage {
+ constructor(...args) {
+ super(...args);
+
+ mockStateStorage = this; // eslint-disable-line consistent-this
+
+ jest.spyOn(this, 'save');
+ jest.spyOn(this, 'read');
+ }
+ }
+
+ return SpiedStateStorage;
+});
+
afterEach(() => {
mockNextScrollBehavior = undefined;
});
@@ -173,3 +191,51 @@ it('should allow changing shouldUpdateScroll', () => {
expect(shouldUpdateScroll1).toHaveBeenCalledTimes(1);
expect(shouldUpdateScroll2).toHaveBeenCalledTimes(1);
});
+
+it('allows setting restoreSameLocation', () => {
+ const MyComponent = () => {
+ useContext(RouterScrollContext);
+
+ return null;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(mockStateStorage.restoreSameLocation).toBe(false);
+
+ render(
+
+
+ ,
+ );
+
+ expect(mockStateStorage.restoreSameLocation).toBe(true);
+});
+
+it('allows changing restoreSameLocation', () => {
+ const MyComponent = () => {
+ useContext(RouterScrollContext);
+
+ return null;
+ };
+
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(mockStateStorage.restoreSameLocation).toBe(false);
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockStateStorage.restoreSameLocation).toBe(true);
+});
diff --git a/src/scroll-behavior/NextScrollBehavior.browser.js b/src/scroll-behavior/NextScrollBehavior.browser.js
index 6ab2221..f323386 100644
--- a/src/scroll-behavior/NextScrollBehavior.browser.js
+++ b/src/scroll-behavior/NextScrollBehavior.browser.js
@@ -12,9 +12,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
_context;
_prevContext;
_debounceSavePositionMap = new Map();
+ _stateStorage;
- constructor(shouldUpdateScroll) {
+ constructor(shouldUpdateScroll, restoreSameLocation = false) {
setupRouter();
+ const stateStorage = new StateStorage({ restoreSameLocation });
super({
addNavigationListener: (callback) => {
@@ -37,10 +39,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
};
},
getCurrentLocation: () => this._context.location,
- stateStorage: new StateStorage(),
+ stateStorage,
shouldUpdateScroll,
});
+ this._stateStorage = stateStorage;
this._context = this._createContext();
this._prevContext = null;
@@ -64,6 +67,10 @@ export default class NextScrollBehavior extends ScrollBehavior {
super.updateScroll(prevContext, context);
}
+ setRestoreSameLocation(newValue = false) {
+ this._stateStorage.restoreSameLocation = newValue;
+ }
+
stop() {
super.stop();
diff --git a/src/scroll-behavior/NextScrollBehavior.browser.test.js b/src/scroll-behavior/NextScrollBehavior.browser.test.js
index 9a4bd9a..fb02a4c 100644
--- a/src/scroll-behavior/NextScrollBehavior.browser.test.js
+++ b/src/scroll-behavior/NextScrollBehavior.browser.test.js
@@ -73,6 +73,12 @@ describe('constructor()', () => {
expect(scrollBehavior._shouldUpdateScroll).toBe(shouldUpdateScroll);
});
+ it('should forward restoreSameLocation to StateStorage', () => {
+ scrollBehavior = new NextScrollBehavior(() => {}, true);
+
+ expect(mockStateStorage.restoreSameLocation).toBe(true);
+ });
+
it('should set history.scrollRestoration to manual, even on Safari iOS', () => {
// eslint-disable-next-line max-len
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 () => {
expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
});
+
+it('should restore scroll position if same url is opened', async () => {
+ scrollBehavior = new NextScrollBehavior(undefined, true);
+
+ jest.spyOn(scrollBehavior, 'scrollToTarget');
+ Object.defineProperty(scrollBehavior, '_numWindowScrollAttempts', {
+ get: () => 1000,
+ set: () => {},
+ });
+
+ // First page
+ history.replaceState({ as: '/' }, '', '/');
+ Router.events.emit('routeChangeComplete', '/');
+ window.pageYOffset = 0;
+ scrollBehavior.updateScroll();
+
+ await sleep(10);
+
+ expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(1, window, [0, 0]);
+
+ // Navigate to new page & scroll
+ history.pushState({ as: '/page2' }, '', '/page2');
+ Router.events.emit('routeChangeComplete', '/');
+ window.pageYOffset = 123;
+ window.dispatchEvent(new CustomEvent('scroll'));
+
+ await sleep(200);
+
+ scrollBehavior.updateScroll();
+
+ expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(2, window, [0, 123]);
+
+ // Go to previous page
+ history.pushState({ as: '/' }, '', '/');
+ Router.events.emit('routeChangeComplete', '/');
+ await sleep(10);
+
+ location.key = history.state.locationKey;
+ scrollBehavior.updateScroll();
+
+ expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(3, window, [0, 0]);
+
+ // Go to next page
+ history.pushState({ as: '/page2' }, '', '/page2');
+ Router.events.emit('routeChangeComplete', '/');
+ await sleep(10);
+
+ location.key = history.state.locationKey;
+ scrollBehavior.updateScroll();
+
+ expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
+});
diff --git a/src/scroll-behavior/StateStorage.js b/src/scroll-behavior/StateStorage.js
index ed32c74..9528461 100644
--- a/src/scroll-behavior/StateStorage.js
+++ b/src/scroll-behavior/StateStorage.js
@@ -1,9 +1,18 @@
/* istanbul ignore file */
import { readState, saveState } from 'history/lib/DOMStateStorage';
+import md5 from 'md5';
const STATE_KEY_PREFIX = '@@scroll|';
+const hashLocation = (location) => md5(`${location.host}${location.pathname}${location.hash}${location.search}`);
+
export default class StateStorage {
+ restoreSameLocation;
+
+ constructor({ restoreSameLocation }) {
+ this.restoreSameLocation = restoreSameLocation || false;
+ }
+
read(location, key) {
return readState(this.getStateKey(location, key));
}
@@ -13,7 +22,8 @@ export default class StateStorage {
}
getStateKey(location, key) {
- const locationKey = location.key ?? '_default';
+ const locationKey = this.restoreSameLocation ? hashLocation(location) : (location.key ?? '_default');
+
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;