├── .babelrc.js ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── codecov.yml ├── karma.conf.js ├── package.json ├── renovate.json ├── src ├── index.js └── utils.js ├── test ├── .eslintrc.js ├── ScrollBehavior.test.js ├── histories.js ├── index.js ├── mockPageLifecycle.js ├── routes.js ├── run.js └── withScroll.js ├── types ├── index.d.ts ├── test.ts ├── tsconfig.json └── tslint.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => ({ 2 | presets: [ 3 | [ 4 | '@4c', 5 | { 6 | targets: {}, 7 | modules: api.env() === 'esm' ? false : 'commonjs', 8 | }, 9 | ], 10 | ], 11 | plugins: [api.env() !== 'esm' && 'add-module-exports'].filter(Boolean), 12 | 13 | env: { 14 | test: { 15 | plugins: ['istanbul'], 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.babelrc.js 2 | !.eslintrc.js 3 | 4 | **/coverage/ 5 | **/lib/ 6 | **/es/ 7 | **/node_modules/ 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['4catalyzer', 'prettier'], 3 | plugins: ['prettier'], 4 | env: { 5 | browser: true, 6 | }, 7 | rules: { 8 | 'prettier/prettier': 'error', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .pnp.* 116 | 117 | # Transpiled code. 118 | /lib 119 | /es 120 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | 5 | jobs: 6 | include: 7 | - addons: 8 | chrome: stable 9 | env: BROWSER=ChromeCi 10 | - addons: 11 | firefox: latest 12 | env: BROWSER=Firefox 13 | 14 | services: 15 | - xvfb 16 | 17 | cache: 18 | yarn: true 19 | npm: true 20 | 21 | after_script: 22 | - node_modules/.bin/codecov 23 | 24 | branches: 25 | only: 26 | - master 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 all 13 | 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 THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scroll-behavior [![Travis][build-badge]][build] [![npm][npm-badge]][npm] 2 | 3 | Pluggable browser scroll management. 4 | 5 | **This library is not generally meant to be used directly by applications. Instead, it's meant to be used in integrations for routing libraries or frameworks. For examples of such integrations, see:** 6 | 7 | - [Found Scroll](https://github.com/4Catalyzer/found-scroll): Integration for [Found](https://github.com/4Catalyzer/found) 8 | - [react-router-scroll](https://github.com/taion/react-router-scroll): Integration for [React Router](https://github.com/reactjs/react-router) v2 and v3 9 | 10 | [![Codecov][codecov-badge]][codecov] 11 | [![Discord][discord-badge]][discord] 12 | 13 | ## Usage 14 | 15 | ```js 16 | import ScrollBehavior from 'scroll-behavior'; 17 | 18 | /* ... */ 19 | 20 | const scrollBehavior = new ScrollBehavior({ 21 | addNavigationListener, 22 | stateStorage, 23 | getCurrentLocation, 24 | /* shouldUpdateScroll, */ 25 | }); 26 | 27 | // After navigation: 28 | scrollBehavior.updateScroll(/* prevContext, context */); 29 | ``` 30 | 31 | ## Guide 32 | 33 | ### Installation 34 | 35 | ``` 36 | $ npm i -S scroll-behavior 37 | ``` 38 | 39 | ### Basic usage 40 | 41 | Create a `ScrollBehavior` object with the following arguments: 42 | 43 | - `addNavigationListener`: this function should take a navigation listener function and return an unlisten function 44 | - The navigation listener function should be called immediately before navigation updates the page 45 | - The unlisten function should remove the navigation listener when called 46 | - `stateStorage`: this object should implement `read` and `save` methods 47 | - The `save` method should take a location object, a nullable element key, and a truthy value; it should save that value for the duration of the page session 48 | - The `read` method should take a location object and a nullable element key; it should return the value that `save` was called with for that location and element key, or a falsy value if no saved value is available 49 | - `getCurrentLocation`: this function should return the current location object 50 | 51 | This object will keep track of the scroll position. Call the `updateScroll` method on this object after navigation to emulate the default browser scroll behavior on page changes. 52 | 53 | Call the `stop` method to tear down all listeners. 54 | 55 | ### Custom scroll behavior 56 | 57 | You can customize the scroll behavior by providing a `shouldUpdateScroll` callback when constructing the `ScrollBehavior` object. When you call `updateScroll`, you can pass in up to two additional context arguments, which will get passed to this callback. 58 | 59 | The callback can return: 60 | 61 | - a falsy value to suppress updating the scroll position 62 | - a position array of `x` and `y`, such as `[0, 100]`, to scroll to that position 63 | - a string with the `id` or `name` of an element, to scroll to that element 64 | - a truthy value to emulate the browser default scroll behavior 65 | 66 | Assuming we call `updateScroll` with the previous and current location objects: 67 | 68 | ```js 69 | const scrollBehavior = new ScrollBehavior({ 70 | ...options, 71 | shouldUpdateScroll: (prevLocation, location) => 72 | // Don't scroll if the pathname is the same. 73 | !prevLocation || location.pathname !== prevLocation.pathname, 74 | }); 75 | ``` 76 | 77 | ```js 78 | const scrollBehavior = new ScrollBehavior({ 79 | ...options, 80 | shouldUpdateScroll: (prevLocation, location) => 81 | // Scroll to top when attempting to visit the current path. 82 | prevLocation && location.pathname === prevLocation.pathname 83 | ? [0, 0] 84 | : true, 85 | }); 86 | ``` 87 | 88 | ### Scrolling elements other than `window` 89 | 90 | Call the `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 should also be called with the current context per `updateScroll` above, if applicable, to set up the element's initial scroll position. 91 | 92 | ```js 93 | scrollBehavior.registerScrollElement( 94 | key, 95 | element, 96 | shouldUpdateScroll, 97 | context, 98 | ); 99 | ``` 100 | 101 | To unregister an element, call the `unregisterElement` method with the key used to register that element. 102 | 103 | ### Further scroll behavior customization 104 | 105 | If you need to further customize scrolling behavior, subclass the `ScrollBehavior` class, then override methods as needed. For example, with the appropriate polyfill, you can override `scrollToTarget` to use smooth scrolling for `window`. 106 | 107 | ```js 108 | class SmoothScrollBehavior extends ScrollBehavior { 109 | scrollToTarget(element, target) { 110 | if (element !== window) { 111 | super.scrollToTarget(element, target); 112 | return; 113 | } 114 | 115 | if (typeof target === 'string') { 116 | const targetElement = 117 | document.getElementById(target) || 118 | document.getElementsByName(target)[0]; 119 | if (targetElement) { 120 | targetElement.scrollIntoView({ behavior: 'smooth' }); 121 | return; 122 | } 123 | 124 | // Fallback to scrolling to top when target fragment doesn't exist. 125 | target = [0, 0]; // eslint-disable-line no-param-reassign 126 | } 127 | 128 | const [left, top] = target; 129 | window.scrollTo({ left, top, behavior: 'smooth' }); 130 | } 131 | } 132 | ``` 133 | 134 | Integrations should accept a `createScrollBehavior` callback that can create an instance of a custom scroll behavior class. 135 | 136 | [build-badge]: https://img.shields.io/travis/taion/scroll-behavior/master.svg 137 | [build]: https://travis-ci.org/taion/scroll-behavior 138 | [npm-badge]: https://img.shields.io/npm/v/scroll-behavior.svg 139 | [npm]: https://www.npmjs.org/package/scroll-behavior 140 | [codecov-badge]: https://img.shields.io/codecov/c/github/taion/scroll-behavior/master.svg 141 | [codecov]: https://codecov.io/gh/taion/scroll-behavior 142 | [discord-badge]: https://img.shields.io/badge/Discord-join%20chat%20%E2%86%92-738bd7.svg 143 | [discord]: https://discord.gg/0ZcbPKXt5bYaNQ46 144 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); // eslint-disable-line import/no-extraneous-dependencies 2 | 3 | module.exports = (config) => { 4 | const { env } = process; 5 | 6 | config.set({ 7 | frameworks: ['mocha', 'sinon-chai'], 8 | 9 | files: ['test/index.js'], 10 | 11 | preprocessors: { 12 | 'test/index.js': ['webpack', 'sourcemap'], 13 | }, 14 | 15 | webpack: { 16 | mode: 'development', 17 | module: { 18 | rules: [ 19 | { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' }, 20 | ], 21 | }, 22 | plugins: [ 23 | new webpack.DefinePlugin({ 24 | 'process.env.NODE_ENV': JSON.stringify('test'), 25 | __DEV__: true, 26 | }), 27 | ], 28 | devtool: 'cheap-module-inline-source-map', 29 | }, 30 | 31 | webpackMiddleware: { 32 | noInfo: true, 33 | }, 34 | 35 | reporters: ['mocha', 'coverage'], 36 | 37 | mochaReporter: { 38 | output: 'autowatch', 39 | }, 40 | 41 | coverageReporter: { 42 | type: 'lcov', 43 | dir: 'coverage', 44 | }, 45 | 46 | customLaunchers: { 47 | ChromeCi: { 48 | base: 'Chrome', 49 | flags: ['--no-sandbox'], 50 | }, 51 | }, 52 | 53 | browsers: env.BROWSER ? env.BROWSER.split(',') : ['Chrome', 'Firefox'], 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll-behavior", 3 | "version": "0.11.0", 4 | "description": "Pluggable browser scroll management", 5 | "files": [ 6 | "lib", 7 | "es" 8 | ], 9 | "main": "lib/index.js", 10 | "module": "es/index.js", 11 | "types": "lib/index.d.ts", 12 | "scripts": { 13 | "build": "npm run build:cjs && npm run build:esm && npm run build:types", 14 | "build:cjs": "babel -d lib --delete-dir-on-start src", 15 | "build:esm": "babel --env-name esm -d es --delete-dir-on-start src", 16 | "build:types": "cpy types/*.d.ts lib", 17 | "format": "eslint --fix . && npm run prettier -- --write", 18 | "lint": "eslint . && npm run prettier -- -l", 19 | "prepublish": "npm run build", 20 | "prettier": "prettier --ignore-path .eslintignore '**/*.{md,ts,tsx}'", 21 | "tdd": "cross-env NODE_ENV=test karma start", 22 | "test": "npm run lint && npm run test:ts && npm run testonly", 23 | "test:ts": "dtslint types", 24 | "testonly": "npm run tdd -- --single-run" 25 | }, 26 | "husky": { 27 | "hooks": { 28 | "pre-commit": "lint-staged" 29 | } 30 | }, 31 | "lint-staged": { 32 | "*.js": "eslint --fix", 33 | "*.{md,ts,tsx}": "prettier --write" 34 | }, 35 | "prettier": { 36 | "printWidth": 79, 37 | "singleQuote": true, 38 | "trailingComma": "all" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/taion/scroll-behavior.git" 43 | }, 44 | "keywords": [ 45 | "scroll" 46 | ], 47 | "author": "Jimmy Jia", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/taion/scroll-behavior/issues" 51 | }, 52 | "homepage": "https://github.com/taion/scroll-behavior#readme", 53 | "dependencies": { 54 | "dom-helpers": "^5.1.4", 55 | "invariant": "^2.2.4", 56 | "page-lifecycle": "^0.1.2" 57 | }, 58 | "devDependencies": { 59 | "@4c/babel-preset": "^7.4.1", 60 | "@babel/cli": "^7.12.13", 61 | "@babel/core": "^7.12.13", 62 | "@babel/polyfill": "^7.12.1", 63 | "babel-loader": "^8.2.2", 64 | "babel-plugin-add-module-exports": "^1.0.4", 65 | "babel-plugin-istanbul": "^6.0.0", 66 | "babel-polyfill": "^6.26.0", 67 | "chai": "^4.3.0", 68 | "codecov": "^3.8.1", 69 | "cpy-cli": "^3.1.1", 70 | "cross-env": "^7.0.3", 71 | "dirty-chai": "^2.0.1", 72 | "dtslint": "^3.7.0", 73 | "eslint": "^7.19.0", 74 | "eslint-config-4catalyzer": "^1.1.5", 75 | "eslint-config-prettier": "^6.15.0", 76 | "eslint-plugin-import": "^2.22.1", 77 | "eslint-plugin-prettier": "^3.3.1", 78 | "history": "^2.1.2", 79 | "husky": "^4.3.8", 80 | "karma": "^5.2.3", 81 | "karma-chrome-launcher": "^3.1.0", 82 | "karma-coverage": "^2.0.3", 83 | "karma-firefox-launcher": "^1.3.0", 84 | "karma-mocha": "^2.0.1", 85 | "karma-mocha-reporter": "^2.2.5", 86 | "karma-sinon-chai": "^2.0.2", 87 | "karma-sourcemap-loader": "^0.3.8", 88 | "karma-webpack": "^4.0.2", 89 | "lint-staged": "^10.5.4", 90 | "mocha": "^8.2.1", 91 | "prettier": "^2.2.1", 92 | "sinon": "^9.2.4", 93 | "sinon-chai": "^3.5.0", 94 | "typescript": "^4.1.3", 95 | "webpack": "^4.46.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>4Catalyzer/renovate-config:library", ":automergeMinor"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | import * as animationFrame from 'dom-helpers/animationFrame'; 4 | import scrollLeft from 'dom-helpers/scrollLeft'; 5 | import scrollTop from 'dom-helpers/scrollTop'; 6 | import invariant from 'invariant'; 7 | import PageLifecycle from 'page-lifecycle/dist/lifecycle.es5'; 8 | 9 | import { isMobileSafari } from './utils'; 10 | 11 | // Try at most this many times to scroll, to avoid getting stuck. 12 | const MAX_SCROLL_ATTEMPTS = 2; 13 | 14 | export default class ScrollBehavior { 15 | constructor({ 16 | addNavigationListener, 17 | stateStorage, 18 | getCurrentLocation, 19 | shouldUpdateScroll, 20 | }) { 21 | this._stateStorage = stateStorage; 22 | this._getCurrentLocation = getCurrentLocation; 23 | this._shouldUpdateScroll = shouldUpdateScroll; 24 | this._oldScrollRestoration = null; 25 | 26 | // This helps avoid some jankiness in fighting against the browser's 27 | // default scroll behavior on `POP` navigations. 28 | /* istanbul ignore else: Travis browsers all support this */ 29 | this._setScrollRestoration(); 30 | 31 | this._saveWindowPositionHandle = null; 32 | this._checkWindowScrollHandle = null; 33 | this._windowScrollTarget = null; 34 | this._numWindowScrollAttempts = 0; 35 | this._ignoreScrollEvents = false; 36 | 37 | this._scrollElements = {}; 38 | 39 | // We have to listen to each window scroll update rather than to just 40 | // location updates, because some browsers will update scroll position 41 | // before emitting the location change. 42 | window.addEventListener('scroll', this._onWindowScroll); 43 | 44 | const handleNavigation = (saveWindowPosition) => { 45 | animationFrame.cancel(this._saveWindowPositionHandle); 46 | this._saveWindowPositionHandle = null; 47 | 48 | if (saveWindowPosition && !this._ignoreScrollEvents) { 49 | this._saveWindowPosition(); 50 | } 51 | 52 | Object.keys(this._scrollElements).forEach((key) => { 53 | const scrollElement = this._scrollElements[key]; 54 | animationFrame.cancel(scrollElement.savePositionHandle); 55 | scrollElement.savePositionHandle = null; 56 | 57 | // It's always fine to save element scroll positions here; the browser 58 | // won't modify them. 59 | if (!this._ignoreScrollEvents) { 60 | this._saveElementPosition(key); 61 | } 62 | }); 63 | }; 64 | 65 | this._removeNavigationListener = addNavigationListener(({ action }) => { 66 | // Don't save window position on POP, as the browser may have already 67 | // updated it. 68 | handleNavigation(action !== 'POP'); 69 | }); 70 | 71 | PageLifecycle.addEventListener('statechange', ({ newState }) => { 72 | if ( 73 | newState === 'terminated' || 74 | newState === 'frozen' || 75 | newState === 'discarded' 76 | ) { 77 | handleNavigation(true); 78 | 79 | // Scroll restoration persists across page reloads. We want to reset 80 | // this to the original value, so that we can let the browser handle 81 | // restoring the initial scroll position on server-rendered pages. 82 | this._restoreScrollRestoration(); 83 | } else { 84 | this._setScrollRestoration(); 85 | } 86 | }); 87 | } 88 | 89 | registerElement(key, element, shouldUpdateScroll, context) { 90 | invariant( 91 | !this._scrollElements[key], 92 | 'ScrollBehavior: There is already an element registered for `%s`.', 93 | key, 94 | ); 95 | 96 | const saveElementPosition = () => { 97 | this._saveElementPosition(key); 98 | }; 99 | 100 | const scrollElement = { 101 | element, 102 | shouldUpdateScroll, 103 | savePositionHandle: null, 104 | 105 | onScroll: () => { 106 | if (!scrollElement.savePositionHandle && !this._ignoreScrollEvents) { 107 | scrollElement.savePositionHandle = animationFrame.request( 108 | saveElementPosition, 109 | ); 110 | } 111 | }, 112 | }; 113 | 114 | // In case no scrolling occurs, save the initial position 115 | if (!scrollElement.savePositionHandle && !this._ignoreScrollEvents) { 116 | scrollElement.savePositionHandle = animationFrame.request( 117 | saveElementPosition, 118 | ); 119 | } 120 | 121 | this._scrollElements[key] = scrollElement; 122 | element.addEventListener('scroll', scrollElement.onScroll); 123 | 124 | this._updateElementScroll(key, null, context); 125 | } 126 | 127 | unregisterElement(key) { 128 | invariant( 129 | this._scrollElements[key], 130 | 'ScrollBehavior: There is no element registered for `%s`.', 131 | key, 132 | ); 133 | 134 | const { element, onScroll, savePositionHandle } = this._scrollElements[ 135 | key 136 | ]; 137 | 138 | element.removeEventListener('scroll', onScroll); 139 | animationFrame.cancel(savePositionHandle); 140 | 141 | delete this._scrollElements[key]; 142 | } 143 | 144 | updateScroll(prevContext, context) { 145 | this._updateWindowScroll(prevContext, context).then(() => { 146 | // Save the position immediately after navigation so that if no scrolling 147 | // occurs, there is still a saved position. 148 | if (!this._saveWindowPositionHandle) { 149 | this._saveWindowPositionHandle = animationFrame.request( 150 | this._saveWindowPosition, 151 | ); 152 | } 153 | }); 154 | 155 | Object.keys(this._scrollElements).forEach((key) => { 156 | this._updateElementScroll(key, prevContext, context); 157 | }); 158 | } 159 | 160 | _setScrollRestoration = () => { 161 | if (this._oldScrollRestoration) { 162 | // It's possible that we already set the scroll restoration. 163 | return; 164 | } 165 | if ( 166 | 'scrollRestoration' in window.history && 167 | // Unfortunately, Safari on iOS freezes for 2-6s after the user swipes to 168 | // navigate through history with scrollRestoration being 'manual', so we 169 | // need to detect this browser and exclude it from the following code 170 | // until this bug is fixed by Apple. 171 | !isMobileSafari() 172 | ) { 173 | this._oldScrollRestoration = window.history.scrollRestoration; 174 | try { 175 | window.history.scrollRestoration = 'manual'; 176 | } catch (e) { 177 | this._oldScrollRestoration = null; 178 | } 179 | } 180 | }; 181 | 182 | _restoreScrollRestoration = () => { 183 | /* istanbul ignore if: not supported by any browsers on Travis */ 184 | if (this._oldScrollRestoration) { 185 | try { 186 | window.history.scrollRestoration = this._oldScrollRestoration; 187 | } catch (e) { 188 | /* silence */ 189 | } 190 | this._oldScrollRestoration = null; 191 | } 192 | }; 193 | 194 | stop() { 195 | this._restoreScrollRestoration(); 196 | 197 | window.removeEventListener('scroll', this._onWindowScroll); 198 | this._cancelCheckWindowScroll(); 199 | 200 | this._removeNavigationListener(); 201 | } 202 | 203 | startIgnoringScrollEvents() { 204 | this._ignoreScrollEvents = true; 205 | } 206 | 207 | stopIgnoringScrollEvents() { 208 | this._ignoreScrollEvents = false; 209 | } 210 | 211 | _onWindowScroll = () => { 212 | if (this._ignoreScrollEvents) { 213 | // Don't save the scroll position until navigation is complete. 214 | return; 215 | } 216 | 217 | // It's possible that this scroll operation was triggered by what will be a 218 | // `POP` navigation. Instead of updating the saved location immediately, 219 | // we have to enqueue the update, then potentially cancel it if we observe 220 | // a location update. 221 | if (!this._saveWindowPositionHandle) { 222 | this._saveWindowPositionHandle = animationFrame.request( 223 | this._saveWindowPosition, 224 | ); 225 | } 226 | 227 | if (this._windowScrollTarget) { 228 | const [xTarget, yTarget] = this._windowScrollTarget; 229 | const x = scrollLeft(window); 230 | const y = scrollTop(window); 231 | 232 | if (x === xTarget && y === yTarget) { 233 | this._windowScrollTarget = null; 234 | this._cancelCheckWindowScroll(); 235 | } 236 | } 237 | }; 238 | 239 | _saveWindowPosition = () => { 240 | this._saveWindowPositionHandle = null; 241 | 242 | this._savePosition(null, window); 243 | }; 244 | 245 | _cancelCheckWindowScroll() { 246 | animationFrame.cancel(this._checkWindowScrollHandle); 247 | this._checkWindowScrollHandle = null; 248 | } 249 | 250 | _saveElementPosition(key) { 251 | const scrollElement = this._scrollElements[key]; 252 | scrollElement.savePositionHandle = null; 253 | 254 | this._savePosition(key, scrollElement.element); 255 | } 256 | 257 | _savePosition(key, element) { 258 | this._stateStorage.save(this._getCurrentLocation(), key, [ 259 | scrollLeft(element), 260 | scrollTop(element), 261 | ]); 262 | } 263 | 264 | _updateWindowScroll(prevContext, context) { 265 | // Whatever we were doing before isn't relevant any more. 266 | this._cancelCheckWindowScroll(); 267 | 268 | this._windowScrollTarget = this._getScrollTarget( 269 | null, 270 | this._shouldUpdateScroll, 271 | prevContext, 272 | context, 273 | ); 274 | 275 | // Updating the window scroll position is really flaky. Just trying to 276 | // scroll it isn't enough. Instead, try to scroll a few times until it 277 | // works. 278 | this._numWindowScrollAttempts = 0; 279 | return this._checkWindowScrollPosition(); 280 | } 281 | 282 | _updateElementScroll(key, prevContext, context) { 283 | const { element, shouldUpdateScroll } = this._scrollElements[key]; 284 | 285 | const scrollTarget = this._getScrollTarget( 286 | key, 287 | shouldUpdateScroll, 288 | prevContext, 289 | context, 290 | ); 291 | if (!scrollTarget) { 292 | return; 293 | } 294 | 295 | // Unlike with the window, there shouldn't be any flakiness to deal with 296 | // here. 297 | this.scrollToTarget(element, scrollTarget); 298 | } 299 | 300 | _getDefaultScrollTarget(location) { 301 | const { hash } = location; 302 | if (hash && hash !== '#') { 303 | return hash.charAt(0) === '#' ? hash.slice(1) : hash; 304 | } 305 | return [0, 0]; 306 | } 307 | 308 | _getScrollTarget(key, shouldUpdateScroll, prevContext, context) { 309 | const scrollTarget = shouldUpdateScroll 310 | ? shouldUpdateScroll.call(this, prevContext, context) 311 | : true; 312 | 313 | if ( 314 | !scrollTarget || 315 | Array.isArray(scrollTarget) || 316 | typeof scrollTarget === 'string' 317 | ) { 318 | return scrollTarget; 319 | } 320 | 321 | const location = this._getCurrentLocation(); 322 | 323 | return ( 324 | this._getSavedScrollTarget(key, location) || 325 | this._getDefaultScrollTarget(location) 326 | ); 327 | } 328 | 329 | _getSavedScrollTarget(key, location) { 330 | if (location.action === 'PUSH') { 331 | return null; 332 | } 333 | 334 | return this._stateStorage.read(location, key); 335 | } 336 | 337 | _checkWindowScrollPosition = () => { 338 | this._checkWindowScrollHandle = null; 339 | 340 | // We can only get here if scrollTarget is set. Every code path that unsets 341 | // scroll target also cancels the handle to avoid calling this handler. 342 | // Still, check anyway just in case. 343 | /* istanbul ignore if: paranoid guard */ 344 | if (!this._windowScrollTarget) { 345 | return Promise.resolve(); 346 | } 347 | 348 | this.scrollToTarget(window, this._windowScrollTarget); 349 | 350 | ++this._numWindowScrollAttempts; 351 | 352 | /* istanbul ignore if: paranoid guard */ 353 | if (this._numWindowScrollAttempts >= MAX_SCROLL_ATTEMPTS) { 354 | // This might happen if the scroll position was already set to the target 355 | this._windowScrollTarget = null; 356 | return Promise.resolve(); 357 | } 358 | 359 | return new Promise((resolve) => { 360 | this._checkWindowScrollHandle = animationFrame.request(() => 361 | resolve(this._checkWindowScrollPosition()), 362 | ); 363 | }); 364 | }; 365 | 366 | scrollToTarget(element, target) { 367 | if (typeof target === 'string') { 368 | const targetElement = 369 | document.getElementById(target) || 370 | document.getElementsByName(target)[0]; 371 | if (targetElement) { 372 | targetElement.scrollIntoView(); 373 | return; 374 | } 375 | 376 | // Fallback to scrolling to top when target fragment doesn't exist. 377 | target = [0, 0]; // eslint-disable-line no-param-reassign 378 | } 379 | 380 | const [left, top] = target; 381 | scrollLeft(element, left); 382 | scrollTop(element, top); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function isMobileSafari() { 2 | return ( 3 | /iPad|iPhone|iPod/.test(window.navigator.platform) && 4 | /^((?!CriOS).)*Safari/.test(window.navigator.userAgent) 5 | ); 6 | } 7 | -------------------------------------------------------------------------------- /test/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | mocha: true, 4 | }, 5 | globals: { 6 | expect: false, 7 | }, 8 | rules: { 9 | 'import/no-extraneous-dependencies': [ 10 | 'error', 11 | { 12 | devDependencies: true, 13 | }, 14 | ], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /test/ScrollBehavior.test.js: -------------------------------------------------------------------------------- 1 | import offset from 'dom-helpers/offset'; 2 | import scrollLeft from 'dom-helpers/scrollLeft'; 3 | import scrollTop from 'dom-helpers/scrollTop'; 4 | import createBrowserHistory from 'history/lib/createBrowserHistory'; 5 | import createHashHistory from 'history/lib/createHashHistory'; 6 | import PageLifecycle from 'page-lifecycle/dist/lifecycle.es5'; 7 | import sinon from 'sinon'; 8 | 9 | import { createHashHistoryWithoutKey } from './histories'; 10 | import { setEventListener, triggerEvent } from './mockPageLifecycle'; 11 | import { 12 | withRoutes, 13 | withScrollElement, 14 | withScrollElementRoutes, 15 | } from './routes'; 16 | import run, { delay } from './run'; 17 | import withScroll from './withScroll'; 18 | 19 | describe('ScrollBehavior', () => { 20 | [ 21 | createBrowserHistory, 22 | createHashHistory, 23 | createHashHistoryWithoutKey, 24 | ].forEach((createHistory) => { 25 | describe(createHistory.name, () => { 26 | let unlisten; 27 | 28 | beforeEach(() => { 29 | window.history.scrollRestoration = 'auto'; 30 | }); 31 | 32 | afterEach(() => { 33 | if (unlisten) { 34 | unlisten(); 35 | } 36 | sinon.restore(); 37 | setEventListener(); 38 | }); 39 | 40 | it('sets/restores/resets scrollRestoration on freeze/resume', (done) => { 41 | sinon.replace(PageLifecycle, 'addEventListener', setEventListener); 42 | const history = withScroll(createHistory()); 43 | expect(window.history.scrollRestoration).to.equal('auto'); 44 | unlisten = run(history, [ 45 | () => { 46 | expect(window.history.scrollRestoration).to.equal('manual'); 47 | triggerEvent('frozen', 'hidden'); 48 | expect(window.history.scrollRestoration).to.equal('manual'); 49 | triggerEvent('hidden', 'frozen'); 50 | expect(window.history.scrollRestoration).to.equal('auto'); 51 | triggerEvent('frozen', 'hidden'); 52 | expect(window.history.scrollRestoration).to.equal('manual'); 53 | done(); 54 | }, 55 | ]); 56 | }); 57 | 58 | it('sets/restores scrollRestoration on termination', (done) => { 59 | sinon.replace(PageLifecycle, 'addEventListener', setEventListener); 60 | expect(window.history.scrollRestoration).to.equal('auto'); 61 | const history = withScroll(createHistory()); 62 | unlisten = run(history, [ 63 | () => { 64 | expect(window.history.scrollRestoration).to.equal('manual'); 65 | triggerEvent('hidden', 'terminated'); 66 | expect(window.history.scrollRestoration).to.equal('auto'); 67 | done(); 68 | }, 69 | ]); 70 | }); 71 | 72 | describe('default behavior', () => { 73 | it('should emulate browser scroll behavior', (done) => { 74 | const history = withRoutes(withScroll(createHistory())); 75 | const child1 = document.getElementById('child1'); 76 | const child2 = document.getElementById('child2-id'); 77 | 78 | unlisten = run(history, [ 79 | () => { 80 | // This will be ignored, but will exercise the throttle logic. 81 | scrollTop(window, 10000); 82 | 83 | setTimeout(() => { 84 | scrollTop(window, 15000); 85 | delay(() => history.push('/detail')); 86 | }); 87 | }, 88 | () => { 89 | expect(scrollTop(window)).to.equal(0); 90 | scrollTop(window, 5000); 91 | delay(history.goBack); 92 | }, 93 | (location) => { 94 | expect(location.state).to.not.exist(); 95 | expect(scrollTop(window)).to.equal(15000); 96 | history.push('/detail#child2'); 97 | }, 98 | () => { 99 | expect(scrollTop(window)).to.be.closeTo(offset(child2).top, 2); 100 | history.push('/detail#child1'); 101 | }, 102 | () => { 103 | expect(scrollTop(window)).to.equal(offset(child1).top); 104 | history.push('/detail#unknown-fragment'); 105 | }, 106 | () => { 107 | expect(scrollTop(window)).to.equal(0); 108 | done(); 109 | }, 110 | ]); 111 | }); 112 | 113 | it('should not crash when history is not available', (done) => { 114 | Object.defineProperty(window.history, 'scrollRestoration', { 115 | value: 'auto', 116 | // See https://github.com/taion/scroll-behavior/issues/126 117 | writable: false, 118 | enumerable: true, 119 | configurable: true, 120 | }); 121 | 122 | const history = withRoutes(withScroll(createHistory())); 123 | 124 | unlisten = run(history, [ 125 | () => { 126 | expect(scrollTop(window)).to.equal(0); 127 | 128 | delete window.history.scrollRestoration; 129 | window.history.scrollRestoration = 'auto'; 130 | 131 | done(); 132 | }, 133 | ]); 134 | }); 135 | }); 136 | 137 | describe('custom behavior', () => { 138 | it('should allow scroll suppression', (done) => { 139 | const history = withRoutes( 140 | withScroll( 141 | createHistory(), 142 | (prevLocation, location) => 143 | !prevLocation || prevLocation.pathname !== location.pathname, 144 | ), 145 | ); 146 | 147 | unlisten = run(history, [ 148 | () => { 149 | history.push('/detail'); 150 | }, 151 | () => { 152 | scrollTop(window, 5000); 153 | delay(() => history.push('/detail?key=value')); 154 | }, 155 | () => { 156 | expect(scrollTop(window)).to.equal(5000); 157 | history.push('/'); 158 | }, 159 | () => { 160 | expect(scrollTop(window)).to.equal(0); 161 | done(); 162 | }, 163 | ]); 164 | }); 165 | 166 | it('should ignore scroll events when startIgnoringScrollEvents is used', (done) => { 167 | const history = withRoutes(withScroll(createHistory())); 168 | 169 | unlisten = run(history, [ 170 | () => { 171 | history.startIgnoringScrollEvents(); 172 | scrollTop(window, 5000); 173 | delay(() => history.push('/detail')); 174 | }, 175 | () => { 176 | delay(() => history.goBack()); 177 | }, 178 | () => { 179 | expect(scrollTop(window)).to.equal(0); 180 | history.stopIgnoringScrollEvents(); 181 | scrollTop(window, 2000); 182 | delay(() => history.push('/detail')); 183 | }, 184 | () => { 185 | delay(() => history.goBack()); 186 | }, 187 | () => { 188 | expect(scrollTop(window)).to.equal(2000); 189 | done(); 190 | }, 191 | ]); 192 | }); 193 | 194 | it('should allow custom position', (done) => { 195 | const history = withRoutes( 196 | withScroll(createHistory(), () => [10, 20]), 197 | ); 198 | 199 | unlisten = run(history, [ 200 | () => { 201 | history.push('/detail'); 202 | }, 203 | () => { 204 | history.push('/'); 205 | }, 206 | () => { 207 | expect(scrollLeft(window)).to.equal(10); 208 | expect(scrollTop(window)).to.equal(20); 209 | done(); 210 | }, 211 | ]); 212 | }); 213 | 214 | it('should save position even if it does not change', (done) => { 215 | const history = withRoutes( 216 | withScroll(createHistory(), (prevLoc, loc) => 217 | loc.action === 'PUSH' ? [10, 20] : true, 218 | ), 219 | ); 220 | 221 | unlisten = run(history, [ 222 | () => { 223 | history.push('/detail'); 224 | }, 225 | () => { 226 | history.push('/'); 227 | }, 228 | () => { 229 | history.push('/detail'); 230 | }, 231 | () => history.goBack(), 232 | () => { 233 | expect(scrollLeft(window)).to.equal(10); 234 | expect(scrollTop(window)).to.equal(20); 235 | done(); 236 | }, 237 | ]); 238 | }); 239 | }); 240 | 241 | describe('scroll element', () => { 242 | it('should follow browser scroll behavior', (done) => { 243 | const { container, ...history } = withScrollElement( 244 | withScroll(createHistory(), () => false), 245 | ); 246 | 247 | unlisten = run(history, [ 248 | () => { 249 | scrollTop(container, 10000); 250 | history.push('/other'); 251 | }, 252 | () => { 253 | expect(scrollTop(container)).to.equal(0); 254 | scrollTop(container, 5000); 255 | history.goBack(); 256 | }, 257 | () => { 258 | expect(scrollTop(container)).to.equal(10000); 259 | history.push('/other'); 260 | }, 261 | () => { 262 | expect(scrollTop(container)).to.equal(0); 263 | done(); 264 | }, 265 | ]); 266 | }); 267 | 268 | it('should restore scroll on remount', (done) => { 269 | const { container, ...history } = withScrollElementRoutes( 270 | withScroll(createHistory(), () => false), 271 | ); 272 | 273 | unlisten = run(history, [ 274 | () => { 275 | scrollTop(container, 10000); 276 | history.push('/other'); 277 | }, 278 | () => { 279 | expect(container.scrollHeight).to.equal(100); 280 | expect(scrollTop(container)).to.equal(0); 281 | scrollTop(container, 5000); 282 | history.goBack(); 283 | }, 284 | () => { 285 | expect(container.scrollHeight).to.equal(20000); 286 | expect(scrollTop(container)).to.equal(10000); 287 | done(); 288 | }, 289 | ]); 290 | }); 291 | 292 | it('should save element scroll position immediately', (done) => { 293 | const history1 = withScrollElement( 294 | withScroll(createHistory(), () => false), 295 | ); 296 | 297 | const unlisten1 = run(history1, [ 298 | () => { 299 | expect(scrollTop(history1.container)).to.equal(0); 300 | scrollTop(history1.container, 5000); 301 | 302 | delay(() => { 303 | unlisten1(); 304 | 305 | const history2 = withScrollElement( 306 | withScroll( 307 | createHistory({ resetState: false }), 308 | () => false, 309 | ), 310 | ); 311 | 312 | unlisten = history2.listen(() => { 313 | delay(() => { 314 | expect(scrollTop(history2.container)).to.equal(5000); 315 | done(); 316 | }); 317 | }); 318 | }); 319 | }, 320 | ]); 321 | }); 322 | 323 | it('should ignore scroll events when startIgnoringScrollEvents is used', (done) => { 324 | const history = withScrollElement( 325 | withRoutes(withScroll(createHistory())), 326 | ); 327 | 328 | unlisten = run(history, [ 329 | () => { 330 | history.startIgnoringScrollEvents(); 331 | scrollTop(history.container, 5432); 332 | delay(() => history.push('/detail')); 333 | }, 334 | () => { 335 | delay(() => history.goBack()); 336 | }, 337 | () => { 338 | expect(scrollTop(history.container)).to.equal(0); 339 | history.stopIgnoringScrollEvents(); 340 | scrollTop(history.container, 2000); 341 | delay(() => history.push('/detail')); 342 | }, 343 | () => { 344 | delay(() => history.goBack()); 345 | }, 346 | () => { 347 | expect(scrollTop(history.container)).to.equal(2000); 348 | done(); 349 | }, 350 | ]); 351 | }); 352 | }); 353 | }); 354 | }); 355 | }); 356 | -------------------------------------------------------------------------------- /test/histories.js: -------------------------------------------------------------------------------- 1 | import createHashHistory from 'history/lib/createHashHistory'; 2 | 3 | export function createHashHistoryWithoutKey({ resetState = true } = {}) { 4 | if (resetState) { 5 | // Avoid persistence of stored data from previous tests. 6 | window.sessionStorage.clear(); 7 | } 8 | 9 | return createHashHistory({ queryKey: false }); 10 | } 11 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | 3 | import dirtyChai from 'dirty-chai'; 4 | 5 | global.chai.use(dirtyChai); 6 | 7 | // Ensure all files in src folder are loaded for proper code coverage analysis. 8 | const srcContext = require.context('../src', true, /.*\.js$/); 9 | srcContext.keys().forEach(srcContext); 10 | 11 | const testsContext = require.context('.', true, /\.test\.js$/); 12 | testsContext.keys().forEach(testsContext); 13 | -------------------------------------------------------------------------------- /test/mockPageLifecycle.js: -------------------------------------------------------------------------------- 1 | let listener; 2 | 3 | export const setEventListener = (eventType, callback) => { 4 | listener = callback; 5 | }; 6 | 7 | export const triggerEvent = (oldState, newState) => { 8 | if (listener) { 9 | const event = new Event('statechange'); 10 | event.newState = newState; 11 | event.oldState = oldState; 12 | listener(event); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /test/routes.js: -------------------------------------------------------------------------------- 1 | export function withRoutes(history) { 2 | const container = document.createElement('div'); 3 | document.body.appendChild(container); 4 | 5 | const child1 = document.createElement('div'); 6 | child1.id = 'child1'; 7 | child1.style.height = '100px'; 8 | container.appendChild(child1); 9 | 10 | const child2 = document.createElement('a'); 11 | child2.id = 'child2-id'; 12 | child2.name = 'child2'; 13 | child2.style.height = '100px'; 14 | child2.appendChild(document.createTextNode('link')); 15 | container.appendChild(child2); 16 | 17 | // This will only be called once, so no need to guard. 18 | function listen(listener) { 19 | const unlisten = history.listen((location) => { 20 | listener(location); 21 | 22 | if (location.pathname === '/') { 23 | container.style.height = '20000px'; 24 | container.style.width = '20000px'; 25 | } else { 26 | container.style.height = '10000px'; 27 | container.style.width = '10000px'; 28 | } 29 | }); 30 | 31 | return () => { 32 | unlisten(); 33 | document.body.removeChild(container); 34 | }; 35 | } 36 | 37 | return { 38 | ...history, 39 | listen, 40 | }; 41 | } 42 | 43 | export function withScrollElement(history) { 44 | const container = document.createElement('div'); 45 | container.style.height = '100px'; 46 | container.style.width = '100px'; 47 | container.style.overflow = 'hidden'; 48 | 49 | const element = document.createElement('div'); 50 | element.style.height = '20000px'; 51 | element.style.width = '20000px'; 52 | 53 | container.appendChild(element); 54 | document.body.appendChild(container); 55 | 56 | // This will only be called once, so no need to guard. 57 | function listen(listener) { 58 | const unlisten = history.listen(listener); 59 | 60 | history.registerScrollElement('container', container); 61 | 62 | return () => { 63 | unlisten(); 64 | document.body.removeChild(container); 65 | }; 66 | } 67 | 68 | return { 69 | ...history, 70 | container, 71 | listen, 72 | }; 73 | } 74 | 75 | export function withScrollElementRoutes(history) { 76 | const container = document.createElement('div'); 77 | container.style.height = '100px'; 78 | container.style.width = '100px'; 79 | container.style.overflow = 'hidden'; 80 | document.body.appendChild(container); 81 | 82 | let element; 83 | let unregister; 84 | 85 | // This will only be called once, so no need to guard. 86 | function listen(listener) { 87 | function shouldUpdateScroll(prevLocation, location) { 88 | // Disable the automatic scroll restoration after the POP, to check the 89 | // scroll-on-register behavior. 90 | if (prevLocation && location.action === 'POP') { 91 | return false; 92 | } 93 | 94 | return true; 95 | } 96 | 97 | const unlisten = history.listen((location) => { 98 | listener(location); 99 | 100 | if (location.pathname === '/') { 101 | element = document.createElement('div'); 102 | element.style.height = '20000px'; 103 | element.style.width = '20000px'; 104 | container.appendChild(element); 105 | 106 | unregister = history.registerScrollElement( 107 | 'container', 108 | container, 109 | shouldUpdateScroll, 110 | ); 111 | } else { 112 | container.removeChild(element); 113 | unregister(); 114 | } 115 | }); 116 | 117 | return () => { 118 | unlisten(); 119 | document.body.removeChild(container); 120 | }; 121 | } 122 | 123 | return { 124 | ...history, 125 | container, 126 | listen, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /test/run.js: -------------------------------------------------------------------------------- 1 | export function delay(cb) { 2 | // Give throttled scroll listeners time to settle down. 3 | requestAnimationFrame(() => 4 | requestAnimationFrame(() => requestAnimationFrame(cb)), 5 | ); 6 | } 7 | 8 | export default function run(history, steps) { 9 | window.history.replaceState(null, null, '/'); 10 | 11 | let i = 0; 12 | 13 | return history.listen((location) => { 14 | if (i === steps.length) { 15 | return; 16 | } 17 | 18 | // Wait a extra tick for all the scroll callbacks to fire before checking 19 | // position. 20 | delay(() => steps[i++](location)); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/withScroll.js: -------------------------------------------------------------------------------- 1 | import { readState, saveState } from 'history/lib/DOMStateStorage'; 2 | 3 | import ScrollBehavior from '../src'; 4 | 5 | const STATE_KEY_PREFIX = '@@scroll|'; 6 | 7 | class HistoryStateStorage { 8 | constructor(history) { 9 | this.getFallbackLocationKey = history.createPath; 10 | } 11 | 12 | read(location, key) { 13 | return readState(this.getStateKey(location, key)); 14 | } 15 | 16 | save(location, key, value) { 17 | saveState(this.getStateKey(location, key), value); 18 | } 19 | 20 | getStateKey(location, key) { 21 | const locationKey = location.key || this.getFallbackLocationKey(location); 22 | const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`; 23 | return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; 24 | } 25 | } 26 | 27 | export default function withScroll(history, shouldUpdateScroll) { 28 | // history v2 will invoke the onChange callback synchronously, so 29 | // currentLocation will always be defined when needed. 30 | let currentLocation = null; 31 | 32 | function getCurrentLocation() { 33 | return currentLocation; 34 | } 35 | 36 | let listeners = []; 37 | let scrollBehavior = null; 38 | 39 | function onChange(location) { 40 | const prevLocation = currentLocation; 41 | currentLocation = location; 42 | 43 | listeners.forEach((listener) => { 44 | listener(location); 45 | }); 46 | 47 | scrollBehavior.updateScroll(prevLocation, location); 48 | } 49 | 50 | let unlisten = null; 51 | 52 | function listen(listener) { 53 | if (listeners.length === 0) { 54 | scrollBehavior = new ScrollBehavior({ 55 | addNavigationListener: history.listenBefore, 56 | stateStorage: new HistoryStateStorage(history), 57 | getCurrentLocation, 58 | shouldUpdateScroll, 59 | }); 60 | unlisten = history.listen(onChange); 61 | } 62 | 63 | listeners.push(listener); 64 | listener(currentLocation); 65 | 66 | return () => { 67 | listeners = listeners.filter((item) => item !== listener); 68 | 69 | if (listeners.length === 0) { 70 | scrollBehavior.stop(); 71 | unlisten(); 72 | } 73 | }; 74 | } 75 | 76 | function registerScrollElement(key, element, shouldUpdateElementScroll) { 77 | scrollBehavior.registerElement( 78 | key, 79 | element, 80 | shouldUpdateElementScroll, 81 | currentLocation, 82 | ); 83 | 84 | return () => { 85 | scrollBehavior.unregisterElement(key); 86 | }; 87 | } 88 | 89 | function startIgnoringScrollEvents() { 90 | scrollBehavior.startIgnoringScrollEvents(); 91 | } 92 | 93 | function stopIgnoringScrollEvents() { 94 | scrollBehavior.stopIgnoringScrollEvents(); 95 | } 96 | 97 | return { 98 | ...history, 99 | listen, 100 | registerScrollElement, 101 | startIgnoringScrollEvents, 102 | stopIgnoringScrollEvents, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | 3 | declare module 'scroll-behavior' { 4 | interface NavigationListener { 5 | (): void; 6 | } 7 | 8 | interface LocationBase { 9 | action: 'PUSH' | string; 10 | hash?: string; 11 | } 12 | 13 | type ScrollPosition = [number, number]; 14 | 15 | type ScrollTarget = ScrollPosition | string | boolean | null | undefined; 16 | 17 | interface ShouldUpdateScroll { 18 | (prevContext: TContext | null, context: TContext): ScrollTarget; 19 | } 20 | 21 | interface ScrollBehaviorOptions { 22 | addNavigationListener: (listener: NavigationListener) => () => void; 23 | stateStorage: { 24 | save: ( 25 | location: TLocation, 26 | key: string | null, 27 | value: ScrollPosition, 28 | ) => void; 29 | read: ( 30 | location: TLocation, 31 | key: string | null, 32 | ) => ScrollPosition | null | undefined; 33 | }; 34 | getCurrentLocation: () => TLocation; 35 | shouldUpdateScroll?: ShouldUpdateScroll; 36 | } 37 | 38 | export default class ScrollBehavior< 39 | TLocation extends LocationBase, 40 | TContext 41 | > { 42 | constructor(options: ScrollBehaviorOptions); 43 | 44 | updateScroll: (prevContext: TContext | null, context: TContext) => void; 45 | 46 | registerElement: ( 47 | key: string, 48 | element: HTMLElement, 49 | shouldUpdateScroll: ShouldUpdateScroll | null, 50 | context: TContext, 51 | ) => void; 52 | 53 | unregisterElement: (key: string) => void; 54 | 55 | scrollToTarget: ( 56 | element: HTMLElement, 57 | target: ScrollPosition | string, 58 | ) => void; 59 | 60 | stop(): void; 61 | 62 | startIgnoringScrollEvents(): void; 63 | 64 | stopIgnoringScrollEvents(): void; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /types/test.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | 3 | import ScrollBehavior from 'scroll-behavior'; 4 | 5 | interface Location { 6 | action: string; 7 | pathname: string; 8 | } 9 | 10 | interface Context { 11 | location: Location; 12 | } 13 | 14 | const location = { 15 | action: 'PUSH', 16 | pathname: '/foo', 17 | }; 18 | 19 | const scrollBehavior = new ScrollBehavior({ 20 | addNavigationListener: (_listener) => () => {}, 21 | stateStorage: { 22 | save: (_location, _key, _value) => {}, 23 | read: (_location, _key) => [0, 0], 24 | }, 25 | getCurrentLocation: () => location, 26 | shouldUpdateScroll: (_prevContext, _context) => true, 27 | }); 28 | 29 | scrollBehavior.updateScroll(null, { location }); 30 | scrollBehavior.updateScroll({ location }, { location }); 31 | 32 | // $ExpectError 33 | scrollBehavior.updateScroll(location, location); 34 | 35 | scrollBehavior.registerElement( 36 | 'foo', 37 | document.createElement('DIV'), 38 | () => false, 39 | { location }, 40 | ); 41 | 42 | scrollBehavior.unregisterElement('foo'); 43 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "forceConsistentCasingInFileNames": true, 4 | "lib": ["es2015", "dom"], 5 | "strict": true, 6 | "types": [], 7 | "noEmit": true, 8 | "baseUrl": "." 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /types/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "dtslint/dtslint.json", 3 | "rules": { 4 | "no-single-declare-module": false 5 | } 6 | } 7 | --------------------------------------------------------------------------------