├── .commitlintrc.json ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── node-ci.yml ├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── .gitignore ├── components │ ├── page-links │ │ ├── PageLinks.js │ │ ├── PageLinks.module.css │ │ └── index.js │ └── page-transition │ │ ├── PageTransition.js │ │ ├── PageTransition.module.css │ │ └── index.js ├── next.config.js ├── package-lock.json ├── package.json └── pages │ ├── _app.js │ ├── another-page.js │ ├── another-page.module.css │ ├── async-page.js │ ├── async-page.module.css │ ├── index.js │ └── index.module.css ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── postcss.config.js └── src ├── RouterScrollProvider.js ├── RouterScrollProvider.test.js ├── context.js ├── index.js ├── scroll-behavior ├── NextScrollBehavior.browser.js ├── NextScrollBehavior.browser.test.js ├── NextScrollBehavior.node.js ├── StateStorage.js ├── history.js ├── history.test.js └── index.js ├── use-router-scroll.js ├── use-router-scroll.test.js ├── with-router-scroll.js └── with-router-scroll.test.js /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{package.json,*.yml}] 13 | indent_size = 2 14 | 15 | [{*.md,*.snap}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "@moxy/eslint-config-base/esm", 9 | "@moxy/eslint-config-babel", 10 | "@moxy/eslint-config-react", 11 | "@moxy/eslint-config-jest" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - demo/**/* 7 | 8 | jobs: 9 | 10 | check: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: ['14', '16'] 15 | name: "[v${{ matrix.node-version }}] check" 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v1 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | npm ci 29 | 30 | - name: Run lint & tests 31 | env: 32 | CI: 1 33 | run: | 34 | npm run lint 35 | npm t 36 | 37 | - name: Submit coverage 38 | uses: codecov/codecov-action@v1 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | fail_ci_if_error: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | coverage 4 | lib/ 5 | es/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [2.2.0](https://github.com/moxystudio/next-router-scroll/compare/v2.1.1...v2.2.0) (2021-06-24) 6 | 7 | 8 | ### Features 9 | 10 | * add next 11 compat ([#9](https://github.com/moxystudio/next-router-scroll/issues/9)) ([787b1ca](https://github.com/moxystudio/next-router-scroll/commit/787b1ca9368280935d58eb16842b5316a459ea37)) 11 | 12 | ### [2.1.1](https://github.com/moxystudio/next-router-scroll/compare/v2.1.0...v2.1.1) (2021-03-23) 13 | 14 | ## [2.1.0](https://github.com/moxystudio/next-router-scroll/compare/v2.0.3...v2.1.0) (2021-01-18) 15 | 16 | 17 | ### Features 18 | 19 | * next 10 compat ([#5](https://github.com/moxystudio/next-router-scroll/issues/5)) ([133295c](https://github.com/moxystudio/next-router-scroll/commit/133295c9bffc03a4d1278a15eb142bebb96e4451)) 20 | 21 | ### [2.0.3](https://github.com/moxystudio/next-router-scroll/compare/v2.0.2...v2.0.3) (2020-08-28) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * make unregisterElement idempotent to play better with react ([609fddc](https://github.com/moxystudio/next-router-scroll/commit/609fddca2265a208bdd022c8500e9cd946b7634c)) 27 | 28 | ### [2.0.2](https://github.com/moxystudio/next-router-scroll/compare/v2.0.1...v2.0.2) (2020-08-28) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * remove unnecessary protection ([9fc3ef5](https://github.com/moxystudio/next-router-scroll/commit/9fc3ef5da0f87b72c9244384fe96aa114e6d1e94)) 34 | 35 | ### [2.0.1](https://github.com/moxystudio/next-router-scroll/compare/v2.0.0...v2.0.1) (2020-08-26) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * issues in SSR mode ([a1f4719](https://github.com/moxystudio/next-router-scroll/commit/a1f4719fc7de5b5d56d3b1cac825af4527bf5aa8)) 41 | 42 | ## [2.0.0](https://github.com/moxystudio/next-router-scroll/compare/v1.0.2...v2.0.0) (2020-08-26) 43 | 44 | 45 | ### ⚠ BREAKING CHANGES 46 | 47 | * usage and API changed 48 | 49 | ### Features 50 | 51 | * improve API and rename package ([#4](https://github.com/moxystudio/next-router-scroll/issues/4)) ([a460d9c](https://github.com/moxystudio/next-router-scroll/commit/a460d9c7168b47f198c61cc095f9a7d140aa81e8)) 52 | 53 | ## [2.0.0](https://github.com/moxystudio/next-router-scroll/compare/v1.0.2...v2.0.0) (2020-08-26) 54 | 55 | 56 | ### ⚠ BREAKING CHANGES 57 | 58 | * usage and API changed 59 | 60 | ### Features 61 | 62 | * improve API and rename package ([#4](https://github.com/moxystudio/next-router-scroll/issues/4)) ([a460d9c](https://github.com/moxystudio/next-router-scroll/commit/a460d9c7168b47f198c61cc095f9a7d140aa81e8)) 63 | 64 | ### [1.0.2](https://github.com/moxystudio/next-scroll-behavior/compare/v1.0.1...v1.0.2) (2020-08-07) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * move next as a peer dependency ([b5fe4b6](https://github.com/moxystudio/next-scroll-behavior/commit/b5fe4b64b60813f3bee08cb48fc52c9d2234974d)) 70 | 71 | ### [1.0.1](https://github.com/moxystudio/next-scroll-behavior/compare/v1.0.0...v1.0.1) (2020-06-01) 72 | 73 | ## 1.0.0 (2020-06-01) 74 | 75 | 76 | ### Features 77 | 78 | * scroll restoration ([#1](https://github.com/moxystudio/next-scroll-behavior/issues/1)) ([74178fd](https://github.com/moxystudio/next-scroll-behavior/commit/74178fd08f8ddb3e42981bf829b21f7729f6738f)) 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Made With MOXY Lda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-router-scroll 2 | 3 | [![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][build-status-image]][build-status-url] [![Coverage Status][codecov-image]][codecov-url] [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] 4 | 5 | [npm-url]:https://npmjs.org/package/@moxy/next-router-scroll 6 | [downloads-image]:https://img.shields.io/npm/dm/@moxy/next-router-scroll.svg 7 | [npm-image]:https://img.shields.io/npm/v/@moxy/next-router-scroll.svg 8 | [build-status-url]:https://github.com/moxystudio/next-router-scroll/actions 9 | [build-status-image]:https://img.shields.io/github/workflow/status/moxystudio/next-router-scroll/Node%20CI/master 10 | [codecov-url]:https://codecov.io/gh/moxystudio/next-router-scroll 11 | [codecov-image]:https://img.shields.io/codecov/c/github/moxystudio/next-router-scroll/master.svg 12 | [david-dm-url]:https://david-dm.org/moxystudio/next-router-scroll 13 | [david-dm-image]:https://img.shields.io/david/moxystudio/next-router-scroll.svg 14 | [david-dm-dev-url]:https://david-dm.org/moxystudio/next-router-scroll?type=dev 15 | [david-dm-dev-image]:https://img.shields.io/david/dev/moxystudio/next-router-scroll.svg 16 | 17 | Take control of when scroll is updated and restored in your Next.js projects. 18 | 19 | ## Installation 20 | 21 | ```sh 22 | $ npm install @moxy/next-router-scroll 23 | ``` 24 | 25 | This library is written in modern JavaScript and is published in both CommonJS and ES module transpiled variants. If you target older browsers please make sure to transpile accordingly. 26 | 27 | ## Motivation 28 | 29 | There are some cases where you need to take control on how your application scroll is handled; namely, you may want to restore scroll when user is navigating within your application pages, but you need to do extra work before or after the page has changed, either by using some sort of page transition or any other feature. 30 | 31 | `@moxy/next-router-scroll` makes it easy to update the window scroll position just like a browser would, but programmatically. 32 | 33 | This package is built on top of [scroll-behavior](https://www.npmjs.com/package/scroll-behavior) and it's meant to be used in [Next.js](https://nextjs.org/) applications. It actively listens to Next.js router events, writing the scroll values associated with the current `location` in the `Session Storage` and reading these values whenever `updateScroll()` is called. 34 | 35 | ## Usage 36 | 37 | First install the provider in your app: 38 | 39 | ```js 40 | // pages/_app.js 41 | import { RouterScrollProvider } from '@moxy/next-router-scroll'; 42 | 43 | const App = ({ Component, pageProps }) => ( 44 | 45 | 46 | 47 | ); 48 | 49 | export default App; 50 | ``` 51 | 52 | Then use the hook or HOC to update the scroll whenever you see fit. 53 | 54 | ```js 55 | // pages/index.js 56 | import { useRouterScroll } from '@moxy/next-router-scroll'; 57 | 58 | const Home = () => { 59 | const { updateScroll } = useRouterScroll(); 60 | 61 | useEffect(() => { 62 | updateScroll(); 63 | }, []); 64 | }; 65 | 66 | export default Home; 67 | ``` 68 | 69 | > ⚠️ By default, `` monkey patches Next.js `` component, changing the `scroll` prop default value to `false`. You can disable this behavior by setting the `disableNextLinkScroll` prop to `false`. 70 | 71 | ## API 72 | 73 | ### <RouterScrollProvider /> 74 | 75 | A provider that should be used in your app component. 76 | 77 | #### shouldUpdateScroll? 78 | 79 | Type: `function` 80 | 81 | A function to determine if scroll should be updated or not. 82 | 83 | ```js 84 | // pages/_app.js 85 | import { RouterScrollProvider } from '@moxy/next-router-scroll'; 86 | 87 | const App = ({ Component, pageProps }) => { 88 | const shouldUpdateScroll = useMemo((prevContext, context) => { 89 | // Both arguments have the following shape: 90 | // { 91 | // location, 92 | // router: { pathname, asPath, query } 93 | // } 94 | }, []); 95 | 96 | return ( 97 | 98 | 99 | 100 | ); 101 | }; 102 | 103 | export default App; 104 | ``` 105 | 106 | Check [custom scroll behavior](https://github.com/taion/scroll-behavior#custom-scroll-behavior) for more information. 107 | 108 | > ⚠️ Please note that `prevContext` might be null on the first run. 109 | 110 | #### disableNextLinkScroll? 111 | 112 | Type: `boolean` 113 | Default: true 114 | 115 | 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. 116 | 117 | #### children 118 | 119 | Type: `ReactNode` 120 | 121 | Any React node to render. 122 | 123 | ### useRouterScroll() 124 | 125 | A hook that returns an object with the following shape: 126 | 127 | ```js 128 | { 129 | updateScroll(prevContext?, context?), 130 | registerElement(key, element, shouldUpdateScroll?, context?), 131 | unregisterElement(key) 132 | } 133 | ``` 134 | 135 | #### updateScroll(prevContext?, context?) 136 | 137 | Call `updateScroll` function whenever you want to update the scroll. You may optionally pass `prevContext` and `context` objects which will be available inside [`shouldUpdateScroll`](#shouldupdatescroll). 138 | 139 | Please note that `prevContext` and `context` have default values and any values you pass will be mixed with the default ones. 140 | 141 | **Use With Async Rendering**: 142 | 143 | If you're asyncronously loading DOM elements and need to wait for an element you can utilize [React's approach for measuring DOM nodes](https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node). Here is an example of what that could look like: 144 | 145 | ```js 146 | const MyComponent = () => { 147 | const { updateScroll } = useRouterScroll(); 148 | const divRef = useCallback((node) => { 149 | if (node) { 150 | updateScroll(); 151 | } 152 | }, [updateScroll]); 153 | 154 | 155 | return someCondition ?
hi
: null; 156 | }; 157 | ``` 158 | 159 | #### registerElement(key, element, shouldUpdateScroll?, context?) 160 | 161 | Call `registerElement` method to register an element other than window to have managed scroll behavior. Each of these elements needs to be given a unique key at registration time, and can be given an optional `shouldUpdateScroll` callback that behaves as above. This method can optionally be called with the current context if applicable, to set up the element's initial scroll position. 162 | 163 | #### unregisterElement(key) 164 | 165 | Call `unregisterElement` to unregister a previously registered element, identified by `key`. 166 | 167 | ### withRouterScroll(Component) 168 | 169 | A HOC that injects a `routerScroll` prop, with the same value as the hook variant. 170 | 171 | ```js 172 | import { withRouterScroll } from '@moxy/next-router-scroll'; 173 | 174 | const MyComponent = ({ routerScroll }) => { 175 | // ... 176 | }; 177 | 178 | export default withRouterScroll(MyComponent); 179 | ``` 180 | 181 | ## Tests 182 | 183 | ```sh 184 | $ npm test 185 | $ npm test -- --watch # during development 186 | ``` 187 | 188 | ## Demo 189 | 190 | A demo project is available in the [`/demo`](./demo) folder so you can try out this component. 191 | 192 | First, build the `next-router-scroll` project with: 193 | 194 | ```sh 195 | $ npm run build 196 | ``` 197 | 198 | *Note: Every time a change is made to the package a rebuild is required to reflect those changes on the demo. While developing, it may be a good idea to run the `dev` script, so you won't need to manually run the build after every change* 199 | 200 | ```sh 201 | $ npm run dev 202 | ``` 203 | 204 | To run the demo, do the following inside the demo's folder: 205 | 206 | ```sh 207 | $ npm i 208 | $ npm run dev 209 | ``` 210 | 211 | ## License 212 | 213 | Released under the [MIT License](https://www.opensource.org/licenses/mit-license.php). 214 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (api) => { 4 | api.cache(true); 5 | 6 | return { 7 | ignore: process.env.BABEL_ENV ? ['**/*.test.js', '**/__snapshots__', '**/__mocks__', '**/__fixtures__'] : [], 8 | presets: [ 9 | ['@moxy/babel-preset/lib', { react: true }], 10 | ], 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next/ 3 | out/ 4 | -------------------------------------------------------------------------------- /demo/components/page-links/PageLinks.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | 4 | import styles from './PageLinks.module.css'; 5 | 6 | const PageLinks = () => ( 7 |
8 | 25 |
26 | ); 27 | 28 | export default PageLinks; 29 | -------------------------------------------------------------------------------- /demo/components/page-links/PageLinks.module.css: -------------------------------------------------------------------------------- 1 | .pageLinks { 2 | height: 5rem; 3 | padding: 0 2.5rem; 4 | display: flex; 5 | align-items: center; 6 | background-color: gray; 7 | color: black; 8 | 9 | & a { 10 | color: inherit; 11 | } 12 | 13 | & ul { 14 | padding: 0; 15 | display: flex; 16 | list-style-type: none; 17 | 18 | & li { 19 | padding-right: 1.5rem; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/components/page-links/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageLinks'; 2 | -------------------------------------------------------------------------------- /demo/components/page-transition/PageTransition.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { CSSTransition } from 'react-transition-group'; 4 | 5 | import styles from './PageTransition.module.css'; 6 | 7 | /* istanbul ignore next */ 8 | const getZIndex = (inProp) => !inProp && -1; 9 | 10 | const PageTransition = ({ node, animation, style, in: inProp, onEntered, onExited }) => ( 11 | 24 |
25 | { node } 26 |
27 |
28 | ); 29 | 30 | PageTransition.propTypes = { 31 | node: PropTypes.element.isRequired, 32 | animation: PropTypes.oneOf(['none', 'fade']), 33 | style: PropTypes.object, 34 | in: PropTypes.bool, 35 | onEntered: PropTypes.func, 36 | onExited: PropTypes.func, 37 | }; 38 | 39 | PageTransition.defaultProps = { 40 | in: false, 41 | animation: 'fade', 42 | }; 43 | 44 | export default PageTransition; 45 | -------------------------------------------------------------------------------- /demo/components/page-transition/PageTransition.module.css: -------------------------------------------------------------------------------- 1 | .fade { 2 | height: 100%; 3 | 4 | &.enter { 5 | opacity: 0; 6 | } 7 | 8 | &.enterActive, 9 | &.enterDone { 10 | opacity: 1; 11 | transition: opacity 0.6s 0.3s; 12 | } 13 | 14 | &.exit { 15 | opacity: 1; 16 | } 17 | 18 | &.exitActive, 19 | &.exitDone { 20 | opacity: 0; 21 | transition: opacity 0.3s; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/components/page-transition/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './PageTransition'; 2 | -------------------------------------------------------------------------------- /demo/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | webpack: (config) => { 6 | config.resolve.symlinks = false; 7 | config.resolve.alias.react = path.join(__dirname, '../node_modules/react'); 8 | config.resolve.alias['react-dom'] = path.join(__dirname, '../node_modules/react-dom'); 9 | 10 | return config; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.0.0", 4 | "description": "demo", 5 | "main": "index.js", 6 | "author": "Afonso Reis ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/moxystudio/next-router-scroll.git" 11 | }, 12 | "scripts": { 13 | "dev": "onchange -i -k \"../lib\" \"../dist\" -- next", 14 | "build": "next build", 15 | "start": "next start", 16 | "export": "next export" 17 | }, 18 | "dependencies": { 19 | "@moxy/next-router-scroll": "file:..", 20 | "@moxy/react-page-swapper": "^1.3.0", 21 | "next": "^12.0.0", 22 | "react-transition-group": "^4.4.2" 23 | }, 24 | "devDependencies": { 25 | "onchange": "^6.1.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo/pages/_app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | import PageSwapper from '@moxy/react-page-swapper'; 4 | import { RouterScrollProvider, useRouterScroll } from '@moxy/next-router-scroll'; 5 | import PageTransition from '../components/page-transition'; 6 | 7 | const AppInner = ({ Component, pageProps }) => { 8 | const { updateScroll } = useRouterScroll(); 9 | 10 | return ( 11 | }> 14 | { (props) => } 15 | 16 | ); 17 | }; 18 | 19 | const App = (props) => ( 20 | 21 | 22 | 23 | ); 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /demo/pages/another-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageLinks from '../components/page-links'; 3 | 4 | import styles from './another-page.module.css'; 5 | 6 | const AnotherPage = () => ( 7 |
8 |

Another Page

9 | 10 |

100vh

11 |

300vh

12 |

400vh

13 |
14 | ); 15 | 16 | export default AnotherPage; 17 | -------------------------------------------------------------------------------- /demo/pages/another-page.module.css: -------------------------------------------------------------------------------- 1 | .anotherPage { 2 | min-height: 450vh; 3 | overflow: hidden; 4 | 5 | & h2 { 6 | position: absolute; 7 | width: 100%; 8 | border-bottom: 1px solid green; 9 | color: green; 10 | } 11 | 12 | & h2:nth-of-type(1) { 13 | top: 100vh; 14 | } 15 | 16 | & h2:nth-of-type(2) { 17 | top: 300vh; 18 | } 19 | 20 | & h2:nth-of-type(3) { 21 | top: 400vh 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/pages/async-page.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageLinks from '../components/page-links'; 3 | 4 | import styles from './async-page.module.css'; 5 | 6 | const AsyncPage = () => ( 7 |
8 |

Async Page

9 | 10 |

100vh

11 |

200vh

12 |

300vh

13 |
14 | ); 15 | 16 | AsyncPage.getInitialProps = async () => { 17 | await new Promise((resolve) => setTimeout(resolve, 2000)); 18 | 19 | return { foo: 'bar' }; 20 | }; 21 | 22 | export default AsyncPage; 23 | -------------------------------------------------------------------------------- /demo/pages/async-page.module.css: -------------------------------------------------------------------------------- 1 | .asyncPage { 2 | min-height: 350vh; 3 | overflow: hidden; 4 | 5 | & h2 { 6 | position: absolute; 7 | width: 100%; 8 | border-bottom: 1px solid blue; 9 | color: blue; 10 | } 11 | 12 | & h2:nth-of-type(1) { 13 | top: 100vh; 14 | } 15 | 16 | & h2:nth-of-type(2) { 17 | top: 200vh; 18 | } 19 | 20 | & h2:nth-of-type(3) { 21 | top: 300vh; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PageLinks from '../components/page-links'; 3 | 4 | import styles from './index.module.css'; 5 | 6 | const Home = () => ( 7 |
8 |

Home

9 | 10 |

100vh

11 |

200vh

12 |

300vh

13 |
14 | ); 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /demo/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .home { 2 | min-height: 350vh; 3 | overflow: hidden; 4 | 5 | & h2 { 6 | position: absolute; 7 | width: 100%; 8 | border-bottom: 1px solid blue; 9 | color: blue; 10 | } 11 | 12 | & h2:nth-of-type(1) { 13 | top: 100vh; 14 | } 15 | 16 | & h2:nth-of-type(2) { 17 | top: 200vh; 18 | } 19 | 20 | & h2:nth-of-type(3) { 21 | top: 300vh; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { compose, baseConfig } = require('@moxy/jest-config-base'); 4 | const withWeb = require('@moxy/jest-config-web'); 5 | const { withRTL } = require('@moxy/jest-config-testing-library'); 6 | 7 | module.exports = compose( 8 | baseConfig(), 9 | withWeb(), 10 | withRTL(), 11 | (config) => { 12 | config.setupFilesAfterEnv = [ 13 | ...config.setupFilesAfterEnv, 14 | './jest.setup.js', 15 | ]; 16 | 17 | return config; 18 | }, 19 | ); 20 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | 3 | jest.mock('next/router', () => { 4 | const listenersMap = {}; 5 | 6 | return { 7 | pathname: '/', 8 | route: '/', 9 | query: {}, 10 | asPath: '/', 11 | components: undefined, 12 | events: { 13 | on: jest.fn((name, fn) => { 14 | listenersMap[name] = listenersMap[name] ?? new Set(); 15 | listenersMap[name].add(fn); 16 | }), 17 | off: jest.fn((name, fn) => { 18 | listenersMap[name]?.delete(fn); 19 | }), 20 | emit: jest.fn((name, ...args) => { 21 | listenersMap[name]?.forEach((fn) => fn(...args)); 22 | }), 23 | }, 24 | push: jest.fn(), 25 | replace: jest.fn(), 26 | reload: jest.fn(), 27 | back: jest.fn(), 28 | prefetch: jest.fn(), 29 | beforePopState: jest.fn(), 30 | }; 31 | }); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@moxy/next-router-scroll", 3 | "version": "2.2.0", 4 | "description": "Take control of when scroll is updated and restored in your Next.js projects", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "lib", 9 | "es" 10 | ], 11 | "homepage": "https://github.com/moxystudio/next-router-scroll#readme", 12 | "author": "Afonso Reis ", 13 | "license": "MIT", 14 | "keywords": [ 15 | "next", 16 | "nextjs", 17 | "router", 18 | "scroll", 19 | "scroll-behavior", 20 | "restore" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/moxystudio/next-router-scroll.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/moxystudio/next-router-scroll/issues" 28 | }, 29 | "scripts": { 30 | "dev": "onchange -i -k \"src\" -- npm run build", 31 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src -d lib --delete-dir-on-start", 32 | "build:es": "cross-env BABEL_ENV=es babel src -d es --delete-dir-on-start", 33 | "build": "npm run build:commonjs && npm run build:es", 34 | "test": "jest", 35 | "lint": "eslint --ignore-path .gitignore .", 36 | "prerelease": "npm t && npm run lint && npm run build", 37 | "release": "standard-version", 38 | "postrelease": "git push --follow-tags origin HEAD && npm publish" 39 | }, 40 | "peerDependencies": { 41 | "next": ">=9 <13", 42 | "react": ">=16.8.0 <18", 43 | "react-dom": ">=16.8.0 <18" 44 | }, 45 | "dependencies": { 46 | "history": "^3.0.0", 47 | "hoist-non-react-statics": "^3.3.2", 48 | "lodash": "^4.17.21", 49 | "prop-types": "^15.7.2", 50 | "scroll-behavior": "^0.11.0" 51 | }, 52 | "devDependencies": { 53 | "@babel/cli": "^7.13.10", 54 | "@babel/core": "^7.13.10", 55 | "@commitlint/config-conventional": "^12.0.1", 56 | "@moxy/babel-preset": "^3.2.1", 57 | "@moxy/eslint-config-babel": "^13.0.3", 58 | "@moxy/eslint-config-base": "^13.0.3", 59 | "@moxy/eslint-config-jest": "^13.0.3", 60 | "@moxy/eslint-config-react": "^13.0.3", 61 | "@moxy/jest-config-base": "^6.1.0", 62 | "@moxy/jest-config-testing-library": "^6.1.0", 63 | "@moxy/jest-config-web": "^6.1.0", 64 | "@moxy/postcss-preset": "^4.5.2", 65 | "@testing-library/react": "^12.0.0", 66 | "browser-resolve": "^2.0.0", 67 | "commitlint": "^12.0.1", 68 | "cross-env": "^7.0.3", 69 | "eslint": "^7.22.0", 70 | "husky": "^4.3.8", 71 | "jest": "^26.6.3", 72 | "lint-staged": "^11.0.0", 73 | "next": "^12.0.0", 74 | "onchange": "^7.1.0", 75 | "react": "^17.0.2", 76 | "react-dom": "^17.0.2", 77 | "standard-version": "^9.3.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('@moxy/postcss-preset')(); 4 | -------------------------------------------------------------------------------- /src/RouterScrollProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useMemo } from 'react'; 2 | import Link from 'next/link'; 3 | import PropTypes from 'prop-types'; 4 | import ScrollBehaviorContext from './context'; 5 | import NextScrollBehavior from './scroll-behavior'; 6 | 7 | const Provider = ScrollBehaviorContext.Provider; 8 | 9 | const useDisableNextLinkScroll = (disableNextLinkScroll) => { 10 | const originalDefaultPropsRef = useRef(Link.defaultProps); 11 | const appliedDisableScroll = useRef(false); 12 | 13 | if (!appliedDisableScroll.current && disableNextLinkScroll) { 14 | Link.defaultProps = { ...Link.defaultProps, scroll: false }; 15 | } 16 | 17 | useEffect(() => { 18 | if (!disableNextLinkScroll) { 19 | return; 20 | } 21 | 22 | const originalDefaultProps = originalDefaultPropsRef.current; 23 | 24 | return () => { 25 | Link.defaultProps = originalDefaultProps; 26 | }; 27 | }, [disableNextLinkScroll]); 28 | }; 29 | 30 | const useScrollBehavior = (shouldUpdateScroll) => { 31 | // Create NextScrollBehavior instance once. 32 | const shouldUpdateScrollRef = useRef(); 33 | const scrollBehaviorRef = useRef(); 34 | 35 | shouldUpdateScrollRef.current = shouldUpdateScroll; 36 | 37 | if (!scrollBehaviorRef.current) { 38 | scrollBehaviorRef.current = new NextScrollBehavior( 39 | (...args) => shouldUpdateScrollRef.current(...args), 40 | ); 41 | } 42 | 43 | // Destroy NextScrollBehavior instance when unmonting. 44 | useEffect(() => () => scrollBehaviorRef.current.stop(), []); 45 | 46 | return scrollBehaviorRef.current; 47 | }; 48 | 49 | const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, children }) => { 50 | // Disable next scroll or not. 51 | useDisableNextLinkScroll(disableNextLinkScroll); 52 | 53 | // Get the scroll behavior, creating it just once. 54 | const scrollBehavior = useScrollBehavior(shouldUpdateScroll); 55 | 56 | // Create facade to use as the provider value. 57 | const providerValue = useMemo(() => ({ 58 | updateScroll: scrollBehavior.updateScroll.bind(scrollBehavior), 59 | registerElement: scrollBehavior.registerElement.bind(scrollBehavior), 60 | unregisterElement: scrollBehavior.unregisterElement.bind(scrollBehavior), 61 | }), [scrollBehavior]); 62 | 63 | return ( 64 | 65 | { children } 66 | 67 | ); 68 | }; 69 | 70 | ScrollBehaviorProvider.defaultProps = { 71 | shouldUpdateScroll: () => true, 72 | disableNextLinkScroll: true, 73 | }; 74 | 75 | ScrollBehaviorProvider.propTypes = { 76 | disableNextLinkScroll: PropTypes.bool, 77 | shouldUpdateScroll: PropTypes.func, 78 | children: PropTypes.node, 79 | }; 80 | 81 | export default ScrollBehaviorProvider; 82 | -------------------------------------------------------------------------------- /src/RouterScrollProvider.test.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import Link from 'next/link'; 4 | import RouterScrollContext from './context'; 5 | import RouterScrollProvider from './RouterScrollProvider'; 6 | 7 | let mockNextScrollBehavior; 8 | 9 | jest.mock('./scroll-behavior', () => { 10 | const NextScrollBehavior = jest.requireActual('./scroll-behavior'); 11 | 12 | class SpiedNextScrollBehavior extends NextScrollBehavior { 13 | constructor(...args) { 14 | super(...args); 15 | 16 | mockNextScrollBehavior = this; // eslint-disable-line consistent-this 17 | 18 | jest.spyOn(this, 'updateScroll'); 19 | jest.spyOn(this, 'registerElement'); 20 | jest.spyOn(this, 'unregisterElement'); 21 | jest.spyOn(this, 'stop'); 22 | } 23 | } 24 | 25 | return SpiedNextScrollBehavior; 26 | }); 27 | 28 | afterEach(() => { 29 | mockNextScrollBehavior = undefined; 30 | }); 31 | 32 | it('should provide a facade to the scroll behavior instance', () => { 33 | expect.assertions(7); 34 | 35 | const MyComponent = () => { 36 | const routerScroll = useContext(RouterScrollContext); 37 | 38 | expect(routerScroll).toEqual({ 39 | updateScroll: expect.any(Function), 40 | registerElement: expect.any(Function), 41 | unregisterElement: expect.any(Function), 42 | }); 43 | 44 | mockNextScrollBehavior.updateScroll.mockImplementation(() => {}); 45 | mockNextScrollBehavior.registerElement.mockImplementation(() => {}); 46 | mockNextScrollBehavior.unregisterElement.mockImplementation(() => {}); 47 | 48 | routerScroll.updateScroll({ foo: 'foo' }, { bar: 'bar' }); 49 | routerScroll.registerElement('foo', document.createElement('div')); 50 | routerScroll.unregisterElement('foo'); 51 | 52 | expect(mockNextScrollBehavior.updateScroll).toHaveBeenCalledTimes(1); 53 | expect(mockNextScrollBehavior.updateScroll).toHaveBeenNthCalledWith(1, { foo: 'foo' }, { bar: 'bar' }); 54 | expect(mockNextScrollBehavior.registerElement).toHaveBeenCalledTimes(1); 55 | expect(mockNextScrollBehavior.registerElement).toHaveBeenNthCalledWith(1, 'foo', expect.any(HTMLDivElement)); 56 | expect(mockNextScrollBehavior.unregisterElement).toHaveBeenCalledTimes(1); 57 | expect(mockNextScrollBehavior.unregisterElement).toHaveBeenNthCalledWith(1, 'foo'); 58 | 59 | return null; 60 | }; 61 | 62 | render( 63 | 64 | 65 | , 66 | ); 67 | }); 68 | 69 | it('should disable scroll in Next\'s Link', () => { 70 | const MyComponent = () => null; 71 | 72 | expect(Link.defaultProps).toBe(undefined); 73 | 74 | const { unmount } = render( 75 | 76 | 77 | , 78 | ); 79 | 80 | expect(Link.defaultProps.scroll).toBe(false); 81 | 82 | unmount(); 83 | 84 | expect(Link.defaultProps).toBe(undefined); 85 | }); 86 | 87 | it('should not disable scroll in Next\'s Link if disableNextLinkScroll is false', () => { 88 | const MyComponent = () => null; 89 | 90 | expect(Link.defaultProps).toBe(undefined); 91 | 92 | const { unmount } = render( 93 | 94 | 95 | , 96 | ); 97 | 98 | expect(Link.defaultProps).toBe(undefined); 99 | 100 | unmount(); 101 | 102 | expect(Link.defaultProps).toBe(undefined); 103 | }); 104 | 105 | it('should call scrollBehavior\'s stop when unmounting', () => { 106 | const MyComponent = () => null; 107 | 108 | const { unmount } = render( 109 | 110 | 111 | , 112 | ); 113 | 114 | unmount(); 115 | 116 | expect(mockNextScrollBehavior.stop).toHaveBeenCalledTimes(1); 117 | }); 118 | 119 | it('should memoize the provider value', () => { 120 | const routerScrolls = []; 121 | 122 | const MyComponent = () => { 123 | const routerScroll = useContext(RouterScrollContext); 124 | 125 | routerScrolls.push(routerScroll); 126 | 127 | return null; 128 | }; 129 | 130 | const { rerender } = render( 131 | 132 | 133 | , 134 | ); 135 | 136 | rerender( 137 | 138 | 139 | , 140 | ); 141 | 142 | expect(routerScrolls).toHaveLength(2); 143 | expect(routerScrolls[0]).toBe(routerScrolls[1]); 144 | }); 145 | 146 | it('should allow changing shouldUpdateScroll', () => { 147 | const shouldUpdateScroll1 = jest.fn(() => false); 148 | const shouldUpdateScroll2 = jest.fn(() => false); 149 | 150 | const MyComponent = () => { 151 | const { updateScroll } = useContext(RouterScrollContext); 152 | 153 | updateScroll(); 154 | 155 | return null; 156 | }; 157 | 158 | const { rerender } = render( 159 | 160 | 161 | , 162 | ); 163 | 164 | expect(shouldUpdateScroll1).toHaveBeenCalledTimes(1); 165 | expect(shouldUpdateScroll2).toHaveBeenCalledTimes(0); 166 | 167 | rerender( 168 | 169 | 170 | , 171 | ); 172 | 173 | expect(shouldUpdateScroll1).toHaveBeenCalledTimes(1); 174 | expect(shouldUpdateScroll2).toHaveBeenCalledTimes(1); 175 | }); 176 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const RouterScrollProvider = createContext(); 4 | 5 | export default RouterScrollProvider; 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as RouterScrollProvider } from './RouterScrollProvider'; 2 | export { default as useRouterScroll } from './use-router-scroll'; 3 | export { default as withRouterScroll } from './with-router-scroll'; 4 | -------------------------------------------------------------------------------- /src/scroll-behavior/NextScrollBehavior.browser.js: -------------------------------------------------------------------------------- 1 | import { debounce, pick } from 'lodash'; 2 | import ScrollBehavior from 'scroll-behavior'; 3 | import Router from 'next/router'; 4 | import { setupHistory, setupRouter } from './history'; 5 | import StateStorage from './StateStorage'; 6 | 7 | setupHistory(); 8 | 9 | const SAVE_POSITION_DEBOUNCE_TIME = 150; 10 | 11 | export default class NextScrollBehavior extends ScrollBehavior { 12 | _context; 13 | _prevContext; 14 | _debounceSavePositionMap = new Map(); 15 | 16 | constructor(shouldUpdateScroll) { 17 | setupRouter(); 18 | 19 | super({ 20 | addNavigationListener: (callback) => { 21 | const handleRouteChangeComplete = () => { 22 | this._prevContext = this._context; 23 | this._context = this._createContext(); 24 | 25 | // Call callback but do not save scroll position as it's too early. 26 | // `scroll-behavior@0.9.x` didn't had this behavior, but newer versions have.. 27 | this._cleanupDebouncedSavePosition(); 28 | this.startIgnoringScrollEvents(); 29 | callback({}); 30 | this.stopIgnoringScrollEvents(); 31 | }; 32 | 33 | Router.events.on('routeChangeComplete', handleRouteChangeComplete); 34 | 35 | return () => { 36 | Router.events.off('routeChangeComplete', handleRouteChangeComplete); 37 | }; 38 | }, 39 | getCurrentLocation: () => this._context.location, 40 | stateStorage: new StateStorage(), 41 | shouldUpdateScroll, 42 | }); 43 | 44 | this._context = this._createContext(); 45 | this._prevContext = null; 46 | 47 | // Make sure to use our implementation of _setScrollRestoration as the original one, 48 | // ignores iOS due to a old bug that seems to be fixed. 49 | // See: https://github.com/gatsbyjs/gatsby/issues/11355 and https://github.com/taion/scroll-behavior/issues/128 50 | this._setScrollRestoration = this._setScrollRestorationWithoutUserAgentSniffing; 51 | this._setScrollRestoration(); 52 | } 53 | 54 | updateScroll(prevContext, context) { 55 | prevContext = this._prevContext == null && prevContext == null ? null : { 56 | ...this._prevContext, 57 | ...prevContext, 58 | }; 59 | context = { 60 | ...this._context, 61 | ...context, 62 | }; 63 | 64 | super.updateScroll(prevContext, context); 65 | } 66 | 67 | stop() { 68 | super.stop(); 69 | 70 | this._cleanupDebouncedSavePosition(); 71 | 72 | // Need to unregister elements since ScrollBehavior doesn't do that for us. 73 | // See: https://github.com/taion/scroll-behavior/issues/406 74 | Object.keys(this._scrollElements).forEach((key) => this.unregisterElement(key)); 75 | } 76 | 77 | registerElement(key, element, shouldUpdateScroll, context) { 78 | context = { 79 | ...this._context, 80 | ...context, 81 | }; 82 | 83 | super.registerElement(key, element, shouldUpdateScroll, context); 84 | } 85 | 86 | unregisterElement(key) { 87 | // Make the function idempotent instead of throwing, so that it plays better in React and fast refresh. 88 | if (!this._scrollElements[key]) { 89 | return; 90 | } 91 | 92 | super.unregisterElement(key); 93 | 94 | // Cleanup ongoing debounce if any. 95 | const savePosition = this._debounceSavePositionMap.get(key); 96 | 97 | if (savePosition) { 98 | savePosition.cancel(); 99 | this._debounceSavePositionMap.delete(key); 100 | } 101 | } 102 | 103 | _createContext() { 104 | return { location, router: pick(Router, 'pathname', 'asPath', 'query') }; 105 | } 106 | 107 | _setScrollRestorationWithoutUserAgentSniffing() { 108 | if (this._oldScrollRestoration) { 109 | return; 110 | } 111 | 112 | if ('scrollRestoration' in history) { 113 | this._oldScrollRestoration = history.scrollRestoration; 114 | 115 | try { 116 | history.scrollRestoration = 'manual'; 117 | } catch (e) { 118 | this._oldScrollRestoration = null; 119 | } 120 | } 121 | } 122 | 123 | _savePosition(key, element) { 124 | // Override _savePosition so that writes to storage are debounced. 125 | // See: https://github.com/taion/scroll-behavior/issues/136 126 | let savePosition = this._debounceSavePositionMap.get(key); 127 | 128 | if (!savePosition) { 129 | savePosition = debounce( 130 | super._savePosition.bind(this), 131 | SAVE_POSITION_DEBOUNCE_TIME, 132 | { leading: true }, 133 | ); 134 | 135 | this._debounceSavePositionMap.set(key, savePosition); 136 | } 137 | 138 | savePosition(key, element); 139 | } 140 | 141 | _cleanupDebouncedSavePosition() { 142 | this._debounceSavePositionMap.forEach((savePosition) => savePosition.cancel()); 143 | this._debounceSavePositionMap.clear(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/scroll-behavior/NextScrollBehavior.browser.test.js: -------------------------------------------------------------------------------- 1 | import { pick } from 'lodash'; 2 | import Router from 'next/router'; 3 | import NextScrollBehavior from './NextScrollBehavior.browser'; 4 | 5 | const sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)); 6 | 7 | let mockStateStorage; 8 | let scrollBehavior; 9 | 10 | window.scrollTo = jest.fn(); 11 | 12 | jest.spyOn(window, 'addEventListener'); 13 | jest.spyOn(window, 'removeEventListener'); 14 | jest.spyOn(Router.events, 'on'); 15 | jest.spyOn(Router.events, 'off'); 16 | 17 | jest.mock('./StateStorage', () => { 18 | const StateStorage = jest.requireActual('./StateStorage'); 19 | 20 | class SpiedStateStorage extends StateStorage { 21 | constructor(...args) { 22 | super(...args); 23 | 24 | mockStateStorage = this; // eslint-disable-line consistent-this 25 | 26 | jest.spyOn(this, 'save'); 27 | jest.spyOn(this, 'read'); 28 | } 29 | } 30 | 31 | return SpiedStateStorage; 32 | }); 33 | 34 | beforeAll(() => { 35 | history.scrollRestoration = 'auto'; 36 | }); 37 | 38 | afterEach(() => { 39 | mockStateStorage = undefined; 40 | scrollBehavior?.stop(); 41 | window.pageYOffset = 0; 42 | jest.clearAllMocks(); 43 | }); 44 | 45 | describe('constructor()', () => { 46 | beforeAll(() => { 47 | Object.defineProperty(navigator, 'userAgent', { value: navigator.userAgent, writable: true }); 48 | Object.defineProperty(navigator, 'platform', { value: navigator.platform, writable: true }); 49 | }); 50 | 51 | it('should setup router', () => { 52 | const beforePopState = Router.beforePopState; 53 | 54 | scrollBehavior = new NextScrollBehavior(); 55 | 56 | expect(Router.beforePopState).not.toBe(beforePopState); 57 | }); 58 | 59 | it('should setup listeners', () => { 60 | scrollBehavior = new NextScrollBehavior(); 61 | 62 | expect(window.addEventListener).toHaveBeenCalledTimes(1); 63 | expect(window.removeEventListener).toHaveBeenCalledTimes(0); 64 | expect(Router.events.on).toHaveBeenCalledTimes(1); 65 | expect(Router.events.off).toHaveBeenCalledTimes(0); 66 | }); 67 | 68 | it('should forward shouldUpdateScroll to ScrollBehavior', () => { 69 | const shouldUpdateScroll = () => {}; 70 | 71 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 72 | 73 | expect(scrollBehavior._shouldUpdateScroll).toBe(shouldUpdateScroll); 74 | }); 75 | 76 | it('should set history.scrollRestoration to manual, even on Safari iOS', () => { 77 | // eslint-disable-next-line max-len 78 | 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'; 79 | navigator.platform = 'iPhone'; 80 | 81 | scrollBehavior = new NextScrollBehavior(); 82 | 83 | expect(history.scrollRestoration).toBe('manual'); 84 | }); 85 | 86 | it('should set current context correctly', () => { 87 | Router.pathname = '/bar'; 88 | Router.asPath = '/bar'; 89 | Router.query = {}; 90 | 91 | const router = pick(Router, 'pathname', 'asPath', 'query'); 92 | 93 | scrollBehavior = new NextScrollBehavior(); 94 | 95 | expect(scrollBehavior._context).toEqual({ location, router }); 96 | expect(scrollBehavior._prevContext).toBe(null); 97 | }); 98 | }); 99 | 100 | describe('stop()', () => { 101 | it('should unregister all elements when stopping', () => { 102 | scrollBehavior = new NextScrollBehavior(); 103 | 104 | jest.spyOn(scrollBehavior, 'unregisterElement'); 105 | 106 | scrollBehavior.registerElement('foo', document.createElement('div')); 107 | scrollBehavior.stop(); 108 | 109 | expect(scrollBehavior.unregisterElement).toHaveBeenCalledTimes(1); 110 | }); 111 | 112 | it('should cancel all ongoing _setPosition debouncers', async () => { 113 | scrollBehavior = new NextScrollBehavior(); 114 | 115 | expect(mockStateStorage.save).toHaveBeenCalledTimes(0); 116 | 117 | window.dispatchEvent(new CustomEvent('scroll')); 118 | 119 | await sleep(30); 120 | 121 | window.dispatchEvent(new CustomEvent('scroll')); 122 | 123 | await sleep(30); 124 | 125 | scrollBehavior.stop(); 126 | 127 | await sleep(200); 128 | 129 | expect(mockStateStorage.save).toHaveBeenCalledTimes(1); 130 | }); 131 | 132 | it('should call super', () => { 133 | const scrollBehavior = new NextScrollBehavior(); 134 | 135 | expect(window.removeEventListener).toHaveBeenCalledTimes(0); 136 | expect(Router.events.off).toHaveBeenCalledTimes(0); 137 | 138 | scrollBehavior.stop(); 139 | 140 | expect(window.removeEventListener).toHaveBeenCalledTimes(1); 141 | expect(Router.events.off).toHaveBeenCalledTimes(1); 142 | }); 143 | }); 144 | 145 | describe('updateScroll()', () => { 146 | it('should inject prevContext and context', () => { 147 | const shouldUpdateScroll = jest.fn(() => false); 148 | 149 | Router.pathname = '/'; 150 | Router.asPath = '/?foo=1'; 151 | Router.query = { foo: '1' }; 152 | 153 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 154 | scrollBehavior.updateScroll(); 155 | 156 | const router1 = pick(Router, 'pathname', 'asPath', 'query'); 157 | 158 | expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 159 | 1, 160 | null, 161 | { location: expect.any(Location), router: router1 }, 162 | ); 163 | 164 | Router.pathname = '/bar'; 165 | Router.asPath = '/bar'; 166 | Router.query = {}; 167 | 168 | Router.events.emit('routeChangeComplete'); 169 | scrollBehavior.updateScroll(); 170 | 171 | const router2 = pick(Router, 'pathname', 'asPath', 'query'); 172 | 173 | expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 174 | 2, 175 | { location: expect.any(Location), router: router1 }, 176 | { location: expect.any(Location), router: router2 }, 177 | ); 178 | }); 179 | 180 | it('should shallow merge prevContext and context', () => { 181 | const shouldUpdateScroll = jest.fn(() => false); 182 | 183 | Router.pathname = '/'; 184 | Router.asPath = '/?foo=1'; 185 | Router.query = { foo: '1' }; 186 | 187 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 188 | scrollBehavior.updateScroll({ foo: 'bar' }, { foz: 'baz' }); 189 | 190 | const router = pick(Router, 'pathname', 'asPath', 'query'); 191 | 192 | expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 193 | 1, 194 | { foo: 'bar' }, 195 | { foz: 'baz', location: expect.any(Location), router }, 196 | ); 197 | }); 198 | 199 | it('should call super', () => { 200 | const shouldUpdateScroll = jest.fn(() => false); 201 | 202 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 203 | scrollBehavior.updateScroll(); 204 | 205 | expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); 206 | }); 207 | }); 208 | 209 | describe('registerElement()', () => { 210 | it('should inject context', () => { 211 | const element = document.createElement('div'); 212 | const shouldUpdateScroll = jest.fn(() => false); 213 | 214 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 215 | scrollBehavior.registerElement('foo', element, shouldUpdateScroll); 216 | 217 | const router = pick(Router, 'pathname', 'asPath', 'query'); 218 | 219 | expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); 220 | expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 221 | 1, 222 | null, 223 | { location: expect.any(Location), router }, 224 | ); 225 | }); 226 | 227 | it('should shallow merge context', () => { 228 | const element = document.createElement('div'); 229 | const shouldUpdateScroll = jest.fn(() => false); 230 | 231 | scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); 232 | scrollBehavior.registerElement('foo', element, shouldUpdateScroll, { foo: 'bar' }); 233 | 234 | const router = pick(Router, 'pathname', 'asPath', 'query'); 235 | 236 | expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); 237 | expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 238 | 1, 239 | null, 240 | { foo: 'bar', location: expect.any(Location), router }, 241 | ); 242 | }); 243 | 244 | it('should call super', () => { 245 | const element = document.createElement('div'); 246 | 247 | scrollBehavior = new NextScrollBehavior(); 248 | scrollBehavior.registerElement('foo', element); 249 | 250 | expect(scrollBehavior._scrollElements.foo).toBeTruthy(); 251 | }); 252 | }); 253 | 254 | describe('unregisterElement()', () => { 255 | it('should cancel all ongoing _setPosition debouncers', async () => { 256 | const element = document.createElement('div'); 257 | 258 | scrollBehavior = new NextScrollBehavior(); 259 | scrollBehavior.registerElement('foo', element); 260 | 261 | expect(mockStateStorage.save).toHaveBeenCalledTimes(0); 262 | 263 | element.dispatchEvent(new CustomEvent('scroll')); 264 | 265 | await sleep(30); 266 | 267 | element.dispatchEvent(new CustomEvent('scroll')); 268 | 269 | await sleep(30); 270 | 271 | scrollBehavior.unregisterElement('foo'); 272 | 273 | await sleep(200); 274 | 275 | expect(mockStateStorage.save).toHaveBeenCalledTimes(1); 276 | }); 277 | 278 | it('should be idempotent', () => { 279 | scrollBehavior = new NextScrollBehavior(); 280 | 281 | expect(() => scrollBehavior.unregisterElement('foo')).not.toThrow(); 282 | }); 283 | 284 | it('should call super', () => { 285 | const element = document.createElement('div'); 286 | 287 | scrollBehavior = new NextScrollBehavior(); 288 | scrollBehavior.registerElement('foo', element); 289 | 290 | scrollBehavior.unregisterElement('foo'); 291 | 292 | expect(scrollBehavior._scrollElements.foo).toBe(undefined); 293 | }); 294 | }); 295 | 296 | describe('on route change complete', () => { 297 | it('should update prevContext and context', () => { 298 | Router.pathname = '/'; 299 | Router.asPath = '/?foo=1'; 300 | Router.query = { foo: '1' }; 301 | 302 | const router1 = pick(Router, 'pathname', 'asPath', 'query'); 303 | 304 | scrollBehavior = new NextScrollBehavior(); 305 | 306 | Router.pathname = '/bar'; 307 | Router.asPath = '/bar'; 308 | Router.query = {}; 309 | 310 | const router2 = pick(Router, 'pathname', 'asPath', 'query'); 311 | 312 | Router.events.emit('routeChangeComplete'); 313 | 314 | expect(scrollBehavior._context).toEqual({ location: expect.any(Location), router: router2 }); 315 | expect(scrollBehavior._prevContext).toEqual({ location: expect.any(Location), router: router1 }); 316 | }); 317 | 318 | it('should not save scroll position', async () => { 319 | scrollBehavior = new NextScrollBehavior(); 320 | 321 | Router.events.emit('routeChangeComplete'); 322 | 323 | await sleep(50); 324 | 325 | expect(mockStateStorage.save).toHaveBeenCalledTimes(0); 326 | }); 327 | }); 328 | 329 | it('should update scroll correctly based on history changes', async () => { 330 | scrollBehavior = new NextScrollBehavior(); 331 | 332 | jest.spyOn(scrollBehavior, 'scrollToTarget'); 333 | Object.defineProperty(scrollBehavior, '_numWindowScrollAttempts', { 334 | get: () => 1000, 335 | set: () => {}, 336 | }); 337 | 338 | // First page 339 | history.replaceState({ as: '/' }, '', '/'); 340 | Router.events.emit('routeChangeComplete', '/'); 341 | window.pageYOffset = 0; 342 | scrollBehavior.updateScroll(); 343 | 344 | await sleep(10); 345 | 346 | expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(1, window, [0, 0]); 347 | 348 | // Navigate to new page & scroll 349 | history.pushState({ as: '/page2' }, '', '/page2'); 350 | Router.events.emit('routeChangeComplete', '/'); 351 | window.pageYOffset = 123; 352 | window.dispatchEvent(new CustomEvent('scroll')); 353 | 354 | await sleep(200); 355 | 356 | scrollBehavior.updateScroll(); 357 | 358 | expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(2, window, [0, 123]); 359 | 360 | // Go to previous page 361 | history.back(); 362 | Router.events.emit('routeChangeComplete', '/'); 363 | await sleep(10); 364 | 365 | location.key = history.state.locationKey; 366 | scrollBehavior.updateScroll(); 367 | 368 | expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(3, window, [0, 0]); 369 | 370 | // Go to next page 371 | history.forward(); 372 | Router.events.emit('routeChangeComplete', '/'); 373 | await sleep(10); 374 | 375 | location.key = history.state.locationKey; 376 | scrollBehavior.updateScroll(); 377 | 378 | expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]); 379 | }); 380 | -------------------------------------------------------------------------------- /src/scroll-behavior/NextScrollBehavior.node.js: -------------------------------------------------------------------------------- 1 | export default class NextScrollBehaviorMock { 2 | updateScroll() {} 3 | registerElement() {} 4 | unregisterElement() {} 5 | stop() {} 6 | } 7 | -------------------------------------------------------------------------------- /src/scroll-behavior/StateStorage.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import { readState, saveState } from 'history/lib/DOMStateStorage'; 3 | 4 | const STATE_KEY_PREFIX = '@@scroll|'; 5 | 6 | export default class StateStorage { 7 | read(location, key) { 8 | return readState(this.getStateKey(location, key)); 9 | } 10 | 11 | save(location, key, value) { 12 | saveState(this.getStateKey(location, key), value); 13 | } 14 | 15 | getStateKey(location, key) { 16 | const locationKey = location.key ?? '_default'; 17 | const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`; 18 | 19 | return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/scroll-behavior/history.js: -------------------------------------------------------------------------------- 1 | import { wrap } from 'lodash'; 2 | import Router from 'next/router'; 3 | 4 | const symbol = Symbol('@moxy/next-router-scroll'); 5 | 6 | const createKey = () => Math.random() 7 | .toString(36) 8 | .substr(2, 8); 9 | 10 | export const setupHistory = () => { 11 | if (history[symbol]) { 12 | return; 13 | } 14 | 15 | history.pushState = wrap(history.pushState, (pushState, state, title, url) => { 16 | /* istanbul ignore else*/ 17 | if (state) { 18 | if (history.state?.as !== url) { 19 | state.locationKey = createKey(); 20 | location.key = state.locationKey; 21 | } else { 22 | state.locationKey = location.key; 23 | } 24 | } 25 | 26 | pushState.call(history, state, title, url); 27 | }); 28 | 29 | history.replaceState = wrap(history.replaceState, (replaceState, state, title, url) => { 30 | /* istanbul ignore else*/ 31 | if (state) { 32 | if (history.state?.as !== url) { 33 | state.locationKey = createKey(); 34 | location.key = state.locationKey; 35 | } else { 36 | state.locationKey = location.key; 37 | } 38 | } 39 | 40 | replaceState.call(history, state, title, url); 41 | }); 42 | 43 | Object.defineProperty(history, symbol, { value: true }); 44 | }; 45 | 46 | export const setupRouter = () => { 47 | if (Router[symbol]) { 48 | return; 49 | } 50 | 51 | Router.beforePopState = wrap(Router.beforePopState, (beforePopState, fn) => { 52 | fn = wrap(fn, (fn, state) => { 53 | location.key = state.locationKey; 54 | 55 | return fn(state); 56 | }); 57 | 58 | return beforePopState.call(Router, fn); 59 | }); 60 | 61 | Router.beforePopState(() => true); 62 | 63 | Object.defineProperty(Router, symbol, { value: true }); 64 | }; 65 | -------------------------------------------------------------------------------- /src/scroll-behavior/history.test.js: -------------------------------------------------------------------------------- 1 | import Router from 'next/router'; 2 | import { setupHistory, setupRouter } from './history'; 3 | 4 | describe('setupHistory()', () => { 5 | beforeAll(() => { 6 | setupHistory(); 7 | }); 8 | 9 | it('should setup just once', () => { 10 | const pushState = history.pushState; 11 | 12 | setupHistory(); 13 | 14 | expect(history.pushState).toBe(pushState); 15 | }); 16 | 17 | it('should handle pushState correctly', () => { 18 | const pushStateSpy = jest.spyOn(history, 'pushState'); 19 | 20 | history.pushState({}, '', '/foo'); 21 | 22 | expect(pushStateSpy).toHaveBeenCalledWith({ locationKey: expect.any(String) }, '', '/foo'); 23 | }); 24 | 25 | it('should handle replaceState correctly', () => { 26 | const replaceStateSpy = jest.spyOn(history, 'replaceState'); 27 | 28 | history.replaceState({}, '', '/foo'); 29 | 30 | expect(replaceStateSpy).toHaveBeenCalledWith({ locationKey: expect.any(String) }, '', '/foo'); 31 | }); 32 | 33 | it('should handle pushState correctly when history state is defined', () => { 34 | const pushStateSpy = jest.spyOn(history, 'pushState'); 35 | 36 | history.state.as = '/foobar'; 37 | 38 | history.pushState({ }, '', '/foobar'); 39 | 40 | expect(pushStateSpy).toHaveBeenCalledWith({ locationKey: expect.any(String) }, '', '/foobar'); 41 | }); 42 | 43 | it('should handle replaceState correctly when history state is defined', () => { 44 | const replaceStateSpy = jest.spyOn(history, 'replaceState'); 45 | 46 | history.state.as = '/foobar'; 47 | 48 | history.replaceState({ }, '', '/foobar'); 49 | 50 | expect(replaceStateSpy).toHaveBeenCalledWith({ locationKey: expect.any(String) }, '', '/foobar'); 51 | }); 52 | }); 53 | 54 | describe('setupRouter()', () => { 55 | let originalBeforePopStateSpy; 56 | 57 | beforeAll(() => { 58 | jest.spyOn(Router, 'beforePopState'); 59 | originalBeforePopStateSpy = Router.beforePopState; 60 | 61 | setupRouter(); 62 | }); 63 | 64 | it('should setup just once', () => { 65 | const beforePopState = Router.beforePopState; 66 | 67 | setupRouter(); 68 | 69 | expect(Router.beforePopState).toBe(beforePopState); 70 | }); 71 | 72 | it('should hook into beforePopState to set location.key', () => { 73 | const fn = originalBeforePopStateSpy.mock.calls[0][0]; 74 | 75 | fn({ locationKey: 'foo' }); 76 | 77 | expect(location.key).toBe('foo'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/scroll-behavior/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | 3 | module.exports = typeof window === 'undefined' ? 4 | require('./NextScrollBehavior.node') : 5 | require('./NextScrollBehavior.browser'); 6 | -------------------------------------------------------------------------------- /src/use-router-scroll.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import RouterScrollContext from './context'; 3 | 4 | const useRouterScroll = () => useContext(RouterScrollContext); 5 | 6 | export default useRouterScroll; 7 | -------------------------------------------------------------------------------- /src/use-router-scroll.test.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import RouterScrollContext from './context'; 4 | import RouterScrollProvider from './RouterScrollProvider'; 5 | import useRouterScroll from './use-router-scroll'; 6 | 7 | it('should return the provider value', () => { 8 | expect.assertions(1); 9 | 10 | const MyComponent = () => { 11 | const providerValue = useContext(RouterScrollContext); 12 | const routerScroll = useRouterScroll(); 13 | 14 | expect(routerScroll).toBe(providerValue); 15 | 16 | return null; 17 | }; 18 | 19 | render( 20 | 21 | 22 | , 23 | ); 24 | }); 25 | -------------------------------------------------------------------------------- /src/with-router-scroll.js: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import hoistNonReactStatics from 'hoist-non-react-statics'; 3 | import useRouterScroll from './use-router-scroll'; 4 | 5 | const withRouterScroll = (WrappedComponent) => { 6 | const WithRouterScroll = forwardRef((props, ref) => { 7 | const routerScroll = useRouterScroll(); 8 | 9 | return ( 10 | 11 | ); 12 | }); 13 | 14 | WithRouterScroll.displayName = `withRouterScroll(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; 15 | hoistNonReactStatics(WithRouterScroll, WrappedComponent); 16 | 17 | return WithRouterScroll; 18 | }; 19 | 20 | export default withRouterScroll; 21 | -------------------------------------------------------------------------------- /src/with-router-scroll.test.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef, useContext } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import RouterScrollContext from './context'; 4 | import ScrollBehaviorProvider from './RouterScrollProvider'; 5 | import withRouterScroll from './with-router-scroll'; 6 | 7 | it('should inject routerScroll prop', () => { 8 | expect.assertions(1); 9 | 10 | const MyComponent = withRouterScroll(({ routerScroll }) => { 11 | const providerValue = useContext(RouterScrollContext); 12 | 13 | expect(routerScroll).toBe(providerValue); 14 | 15 | return null; 16 | }); 17 | 18 | render( 19 | 20 | 21 | , 22 | ); 23 | }); 24 | 25 | it('should forward refs', () => { 26 | class MyComponent extends Component { 27 | render() { 28 | return null; 29 | } 30 | 31 | handleClick = () => {}; 32 | } 33 | 34 | const EnhancedMyComponent = withRouterScroll(MyComponent); 35 | 36 | const ref = createRef(); 37 | 38 | render( 39 | 40 | 41 | , 42 | ); 43 | 44 | expect(ref.current.handleClick).toBeDefined(); 45 | }); 46 | 47 | it('should copy statics', () => { 48 | const MyComponent = () => {}; 49 | 50 | MyComponent.foo = 'bar'; 51 | 52 | const EnhancedMyComponent = withRouterScroll(MyComponent); 53 | 54 | expect(EnhancedMyComponent.foo).toBe('bar'); 55 | }); 56 | --------------------------------------------------------------------------------