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}`;