├── .babelrc ├── .eslintrc.yaml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── ElementScroller.js ├── ScrollManager.js ├── WindowScroller.js ├── index.d.ts ├── index.js └── timedMutationObserver.js └── test ├── .eslintrc.yaml ├── ScrollManager.default.test.js ├── ScrollManager.stored.test.js └── mocks.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "entry" 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 2 | - eslint:recommended 3 | - plugin:react/recommended 4 | parser: babel-eslint 5 | parserOptions: 6 | ecmaVersion: 2018 7 | sourceType: module 8 | env: 9 | browser: true 10 | es6: true 11 | node: true 12 | settings: 13 | react: 14 | version: "16.0" 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | .DS_Store 4 | .vscode/ 5 | npm-debug.log* 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 10 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2018, Trevor Robinson 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-scroll-manager 2 | 3 | [![Build Status](https://travis-ci.org/trevorr/react-scroll-manager.svg?branch=master)](https://travis-ci.org/trevorr/react-scroll-manager) 4 | [![npm version](https://badge.fury.io/js/react-scroll-manager.svg)](https://badge.fury.io/js/react-scroll-manager) 5 | 6 | ## Overview 7 | 8 | In a single page application (SPA), the application manipulates the browser history and DOM to simulate navigation. 9 | Because navigation is simulated and rendering is dynamic, the usual browser behavior of restoring scroll position 10 | when navigating back and forth through the history is not generally functional. 11 | While some browsers ([particularly Chrome](https://github.com/brigade/delayed-scroll-restoration-polyfill)) attempt to 12 | support automatic [scroll restoration](https://html.spec.whatwg.org/multipage/history.html#dom-history-scroll-restoration) 13 | in response to history navigation and asynchronous page rendering, this support is still incomplete and inconsistent. 14 | Similarly, SPA router libraries provide varying but incomplete levels of scroll restoration. For example, the current 15 | version of [React Router](https://reacttraining.com/react-router/) 16 | [does not provide scroll management](https://github.com/ReactTraining/react-router/issues/3950), 17 | and older versions did not provide support for all cases. 18 | 19 | This library attempts to provide this missing functionality to [React](https://reactjs.org/) applications in a flexible 20 | and mostly router-agnostic way. It supports saving per-location window and element scroll positions to session storage 21 | and automatically restoring them during navigation. It also provides support for the related problem of navigating to 22 | hash links that reference dynamically rendered elements. 23 | 24 | ## Requirements 25 | 26 | This library has the following requirements: 27 | 28 | - HTML5 browsers: Only the [browser history API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) 29 | (not hash history) is supported. Generally, this means [modern browsers or IE 10+](https://caniuse.com/#feat=history). 30 | - React 16 and higher: The modern [Context API](https://reactjs.org/docs/context.html) is used. 31 | 32 | The following features of newer browsers are supported with fallbacks for older browsers: 33 | 34 | - If [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) is not 35 | [available](https://caniuse.com/#search=MutationObserver) (e.g. IE 10), the library will fall back to polling. 36 | - [Scroll restoration](https://html.spec.whatwg.org/multipage/history.html#dom-history-scroll-restoration) 37 | will be set to `manual` if [available](https://developer.mozilla.org/en-US/docs/Web/API/History_API#Browser_compatibility), 38 | and ignored if not (e.g. IE and Edge). 39 | 40 | ## Installation 41 | 42 | ```sh 43 | npm install react-scroll-manager 44 | ``` 45 | 46 | ## Example 47 | 48 | The following example demonstrates usage of this library with React Router v4. 49 | It includes scroll restoration for both the main content window and a fixed navigation panel. 50 | 51 | ```js 52 | import React from 'react'; 53 | import { Router } from 'react-router-dom'; 54 | import { ScrollManager, WindowScroller, ElementScroller } from 'react-scroll-manager'; 55 | import { createBrowserHistory as createHistory } from 'history'; 56 | 57 | class App extends React.Component { 58 | constructor() { 59 | super(); 60 | this.history = createHistory(); 61 | } 62 | render() { 63 | return ( 64 | 65 | 66 | 67 | 68 |
69 | ... 70 |
71 |
72 |
73 | ... 74 |
75 |
76 |
77 |
78 | ); 79 | } 80 | } 81 | ``` 82 | 83 | ## API 84 | 85 | ### ScrollManager 86 | 87 | The ScrollManager component goes outside of your router component. It enables manual scroll restoration, 88 | reads and writes scroll positions from/to session storage, saves positions before navigation events, handles scrolling 89 | of nested components like WindowScroller and ElementScroller, and performs delayed scrolling to hash links anywhere 90 | within the document. It has the following properties: 91 | 92 | | Name | Type | Required | Description | 93 | |------|------|----------|-------------| 94 | | history | object | yes | A [history](https://github.com/ReactTraining/history) object, as returned by `createBrowserHistory` or `createMemoryHistory`. | 95 | | sessionKey | string | no | The key under which session state is stored. Defaults to `ScrollManager`. | 96 | | timeout | number | no | The maximum number of milliseconds to wait for rendering to complete. Defaults to 3000. | 97 | 98 | ### WindowScroller 99 | 100 | The WindowScroller component goes immediately inside your router component. It handles scrolling the window 101 | position after navigation. If your window position never changes (e.g. your layout is fixed and all scrolling 102 | occurs within elements), it need not be used. It has no properties, but must be nested within a ScrollManager. 103 | 104 | ### ElementScroller 105 | 106 | The ElementScroller component goes immediately outside of a scrollable component (e.g. with `overflow: auto` style) 107 | for which you would like to save and restore the scroll position. It must be nested within a ScrollManager and has 108 | the following required property: 109 | 110 | | Name | Type | Required | Description | 111 | |------|------|----------|-------------| 112 | | scrollKey | string | yes | The key within the session state under which the element scroll position is stored. | 113 | 114 | ## Tips 115 | 116 | ### Use router link elements for hash links within a page 117 | 118 | Always be sure to use your router library's link component rather than `` tags when navigating to hash links. 119 | While a link like `` will navigate to the given element on the current page, it bypasses the usual 120 | call to [`history.pushState`](https://developer.mozilla.org/en-US/docs/Web/API/History_API), which assigns a unique key 121 | to the history location. Without a location key, the library has no way to associate the position with the location, 122 | and scroll restoration won't work for those locations. 123 | 124 | ```html 125 | ... 126 | ... 127 | ``` 128 | 129 | ## Acknowledgments 130 | 131 | - The concept for this library is based on [react-router-restore-scroll](https://github.com/ryanflorence/react-router-restore-scroll). 132 | - The timed [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) approach to scrolling to hash link 133 | elements comes from [Gajus Kuizinas](https://medium.com/@gajus/making-the-anchor-links-work-in-spa-applications-618ba2c6954a). 134 | - Thanks to Anders Gissel for [suggesting a fix for IE 11 window scrolling](https://github.com/trevorr/react-scroll-manager/pull/3) and Søren Bruus Frank for [submitting TypeScript definitions](https://github.com/trevorr/react-scroll-manager/pull/2). 135 | 136 | ## License 137 | 138 | `react-scroll-manager` is available under the [ISC license](LICENSE). 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scroll-manager", 3 | "version": "1.0.3", 4 | "description": "Scroll position manager for React applications", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "src/index.d.ts" 9 | ], 10 | "types": "src/index.d.ts", 11 | "scripts": { 12 | "build": "babel src --out-dir lib", 13 | "lint": "eslint 'src/*.js' 'test/*.js'", 14 | "prepublishOnly": "npm run build", 15 | "prepush": "npm run build && npm test && npm run lint", 16 | "test": "jest" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-push": "npm run prepush" 21 | } 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/trevorr/react-scroll-manager.git" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "router", 30 | "history", 31 | "scroll" 32 | ], 33 | "author": "Trevor Robinson", 34 | "license": "ISC", 35 | "bugs": { 36 | "url": "https://github.com/trevorr/react-scroll-manager/issues" 37 | }, 38 | "homepage": "https://github.com/trevorr/react-scroll-manager#readme", 39 | "dependencies": { 40 | "prop-types": "^15.7.2" 41 | }, 42 | "peerDependencies": { 43 | "react": "^16.0.0", 44 | "react-dom": "^16.0.0" 45 | }, 46 | "devDependencies": { 47 | "@babel/cli": "^7.10.1", 48 | "@babel/core": "^7.10.2", 49 | "@babel/preset-env": "^7.10.2", 50 | "@babel/preset-react": "^7.10.1", 51 | "babel-core": "^7.0.0-bridge.0", 52 | "babel-eslint": "^10.1.0", 53 | "babel-jest": "^26.0.1", 54 | "eslint": "^7.2.0", 55 | "eslint-plugin-react": "^7.20.0", 56 | "history": "^4.10.1", 57 | "husky": "^4.2.5", 58 | "jest": "^26.0.1", 59 | "react": "^16.13.1", 60 | "react-dom": "^16.13.1", 61 | "react-router-dom": "^5.2.0", 62 | "react-test-renderer": "^16.13.1" 63 | }, 64 | "engines": { 65 | "node": ">=10.0.0" 66 | }, 67 | "browserslist": [ 68 | ">0.2%", 69 | "not dead", 70 | "not ie <= 11", 71 | "not op_mini all" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /src/ElementScroller.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ScrollManager, withManager } from './ScrollManager'; 4 | 5 | class ManagedElementScroller extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this._ref = React.createRef(); 9 | } 10 | 11 | componentDidMount() { 12 | this._register(); 13 | } 14 | 15 | componentWillUnmount() { 16 | this._unregister(this.props); 17 | } 18 | 19 | componentDidUpdate(prevProps) { 20 | this._unregister(prevProps); 21 | this._register(); 22 | } 23 | 24 | _register() { 25 | const { manager, scrollKey } = this.props; 26 | const node = this._ref.current; 27 | if (!manager) { 28 | console.warn('ElementScroller only works when nested within a ScrollManager'); // eslint-disable-line no-console 29 | } else if (scrollKey && node) { 30 | manager._registerElement(scrollKey, node); 31 | } 32 | } 33 | 34 | _unregister(props) { 35 | const { manager, scrollKey } = props; 36 | if (manager && scrollKey) { 37 | manager._unregisterElement(scrollKey); 38 | } 39 | } 40 | 41 | render() { 42 | return React.cloneElement(React.Children.only(this.props.children), { ref: this._ref }); 43 | } 44 | } 45 | 46 | ManagedElementScroller.propTypes = { 47 | manager: PropTypes.instanceOf(ScrollManager).isRequired, 48 | scrollKey: PropTypes.string.isRequired, 49 | children: PropTypes.element.isRequired 50 | }; 51 | 52 | export const ElementScroller = withManager(ManagedElementScroller); 53 | -------------------------------------------------------------------------------- /src/ScrollManager.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import timedMutationObserver from './timedMutationObserver'; 4 | 5 | const debug = require('debug')('ScrollManager'); 6 | 7 | const ManagerContext = React.createContext(); 8 | 9 | const defaultTimeout = 3000; 10 | 11 | export class ScrollManager extends React.Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | const { history, sessionKey = 'ScrollManager', timeout = defaultTimeout } = props; 16 | 17 | if ('scrollRestoration' in window.history) { 18 | this._originalScrollRestoration = window.history.scrollRestoration; 19 | window.history.scrollRestoration = 'manual'; 20 | } 21 | 22 | // load positions and associated tracking data from session state 23 | try { 24 | const data = sessionStorage.getItem(sessionKey); 25 | this._session = JSON.parse(data || '{}'); 26 | } catch (e) { 27 | debug('Error reading session storage:', e.message); 28 | this._session = {}; 29 | } 30 | this._positions = this._session.positions || (this._session.positions = {}); 31 | this._locations = this._session.locations || (this._session.locations = []); 32 | this._historyStart = history.length - this._locations.length; 33 | const initialKey = 'initial'; 34 | this._locationKey = this._session.locationKey || initialKey; 35 | 36 | // initialize emphemeral state of scrollable nodes 37 | this._scrollableNodes = {}; 38 | this._deferredNodes = {}; 39 | 40 | window.addEventListener('beforeunload', () => { 41 | // write everything back to session state on unload 42 | this._savePositions(); 43 | this._session.locationKey = this._locationKey; 44 | try { 45 | sessionStorage.setItem(sessionKey, JSON.stringify(this._session)); 46 | } catch (e) { 47 | // session state full or unavailable 48 | } 49 | }); 50 | 51 | this._unlisten = history.listen((location, action) => { 52 | this._savePositions(); 53 | 54 | // cancel any pending hash scroller 55 | if (this._hashScroller) { 56 | this._hashScroller.cancel(); 57 | this._hashScroller = null; 58 | } 59 | 60 | // clean up positions no longer in history to avoid leaking memory 61 | // (including last history element if action is PUSH or REPLACE) 62 | const locationCount = Math.max(0, history.length - this._historyStart - (action !== 'POP' ? 1 : 0)); 63 | while (this._locations.length > locationCount) { 64 | const key = this._locations.pop(); 65 | delete this._positions[key]; 66 | } 67 | 68 | const key = location.key || initialKey; 69 | if (action !== 'POP') { 70 | // track the new location key in our array of locations 71 | this._locations.push(key); 72 | this._historyStart = history.length - this._locations.length; 73 | 74 | // check for hash links that need deferral of scrolling into view 75 | if (typeof location.hash === 'string' && location.hash.length > 1) { 76 | const elementId = location.hash.substring(1); 77 | this._hashScroller = timedMutationObserver(() => { 78 | const element = document.getElementById(elementId); 79 | if (element) { 80 | debug(`Scrolling element ${elementId} into view`); 81 | element.scrollIntoView(); 82 | return true; 83 | } 84 | return false; 85 | }, timeout); 86 | this._hashScroller.catch(e => { 87 | if (!e.cancelled) { 88 | debug(`Timeout scrolling hash element ${elementId} into view`); 89 | } 90 | }); 91 | } 92 | } 93 | 94 | // set current location key for saving position on next history change 95 | this._locationKey = key; 96 | }); 97 | } 98 | 99 | componentWillUnmount() { 100 | if (this._unlisten) { 101 | this._unlisten(); 102 | } 103 | if (this._originalScrollRestoration) { 104 | window.history.scrollRestoration = this._originalScrollRestoration; 105 | } 106 | } 107 | 108 | render() { 109 | return ( 110 | 111 | {this.props.children} 112 | 113 | ); 114 | } 115 | 116 | _registerElement(scrollKey, node) { 117 | this._scrollableNodes[scrollKey] = node; 118 | this._restoreNode(scrollKey); 119 | } 120 | 121 | _unregisterElement(scrollKey) { 122 | delete this._scrollableNodes[scrollKey]; 123 | } 124 | 125 | _savePositions() { 126 | // use pageXOffset instead of scrollX for IE compatibility 127 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX#Notes 128 | const { pageXOffset: scrollX, pageYOffset: scrollY } = window; 129 | this._savePosition('window', { scrollX, scrollY }); 130 | for (const scrollKey in this._scrollableNodes) { 131 | const node = this._scrollableNodes[scrollKey]; 132 | const { scrollLeft, scrollTop } = node; 133 | this._savePosition(scrollKey, { scrollLeft, scrollTop }); 134 | } 135 | } 136 | 137 | _savePosition(scrollKey, position) { 138 | debug('save', this._locationKey, scrollKey, position); 139 | if (!(scrollKey in this._deferredNodes)) { 140 | let loc = this._positions[this._locationKey]; 141 | if (!loc) { 142 | loc = this._positions[this._locationKey] = {}; 143 | } 144 | loc[scrollKey] = position; 145 | } else { 146 | debug(`Skipping save due to deferred scroll of ${scrollKey}`); 147 | } 148 | } 149 | 150 | _loadPosition(scrollKey) { 151 | const loc = this._positions[this._locationKey]; 152 | return loc ? loc[scrollKey] || null : null; 153 | } 154 | 155 | _restoreNode(scrollKey) { 156 | const position = this._loadPosition(scrollKey); 157 | const { scrollLeft = 0, scrollTop = 0 } = position || {}; 158 | debug('restore', this._locationKey, scrollKey, scrollLeft, scrollTop); 159 | 160 | this._cancelDeferred(scrollKey); 161 | const node = this._scrollableNodes[scrollKey]; 162 | const attemptScroll = () => { 163 | node.scrollLeft = scrollLeft; 164 | node.scrollTop = scrollTop; 165 | return node.scrollLeft === scrollLeft && node.scrollTop === scrollTop; 166 | }; 167 | if (!attemptScroll()) { 168 | const failedScroll = () => { 169 | debug(`Could not scroll ${scrollKey} to (${scrollLeft}, ${scrollTop})` + 170 | `; scroll size is (${node.scrollWidth}, ${node.scrollHeight})`); 171 | }; 172 | 173 | const { timeout = defaultTimeout } = this.props; 174 | if (timeout) { 175 | debug(`Deferring scroll of ${scrollKey} for up to ${timeout} ms`); 176 | (this._deferredNodes[scrollKey] = timedMutationObserver(attemptScroll, timeout, node)) 177 | .then(() => delete this._deferredNodes[scrollKey]) 178 | .catch(e => { if (!e.cancelled) failedScroll() }); 179 | } else { 180 | failedScroll(); 181 | } 182 | } 183 | } 184 | 185 | _restoreWindow() { 186 | const scrollKey = 'window'; 187 | const position = this._loadPosition(scrollKey); 188 | const { scrollX = 0, scrollY = 0 } = position || {}; 189 | debug('restore', this._locationKey, scrollKey, scrollX, scrollY); 190 | 191 | this._cancelDeferred(scrollKey); 192 | const attemptScroll = () => { 193 | window.scrollTo(scrollX, scrollY); 194 | return window.pageXOffset === scrollX && window.pageYOffset === scrollY; 195 | }; 196 | if (!attemptScroll()) { 197 | const failedScroll = () => { 198 | debug(`Could not scroll ${scrollKey} to (${scrollX}, ${scrollY})` + 199 | `; scroll size is (${document.body.scrollWidth}, ${document.body.scrollHeight})`); 200 | }; 201 | 202 | const { timeout = defaultTimeout } = this.props; 203 | if (timeout) { 204 | debug(`Deferring scroll of ${scrollKey} for up to ${timeout} ms`); 205 | (this._deferredNodes[scrollKey] = timedMutationObserver(attemptScroll, timeout)) 206 | .then(() => delete this._deferredNodes[scrollKey]) 207 | .catch(e => { if (!e.cancelled) failedScroll() }); 208 | } else { 209 | failedScroll(); 210 | } 211 | } 212 | } 213 | 214 | _restoreInitial() { 215 | if (!location.hash) { 216 | this._restoreWindow(); 217 | } 218 | } 219 | 220 | _cancelDeferred(scrollKey) { 221 | const deferred = this._deferredNodes[scrollKey]; 222 | if (deferred) { 223 | debug(`Cancelling deferred scroll of ${scrollKey}`); 224 | delete this._deferredNodes[scrollKey]; 225 | deferred.cancel(); 226 | } 227 | } 228 | } 229 | 230 | ScrollManager.propTypes = { 231 | history: PropTypes.object.isRequired, 232 | sessionKey: PropTypes.string, 233 | timeout: PropTypes.number, 234 | children: PropTypes.node 235 | }; 236 | 237 | export function withManager(Component) { 238 | return function ManagedComponent(props) { 239 | return ( 240 | 241 | { manager => } 242 | 243 | ) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/WindowScroller.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ScrollManager, withManager } from './ScrollManager'; 4 | 5 | class ManagedWindowScroller extends React.Component { 6 | componentDidMount() { 7 | const { manager } = this.props; 8 | if (manager) { 9 | manager._restoreInitial(); 10 | } else { 11 | console.warn('WindowScroller only works when nested within a ScrollManager'); // eslint-disable-line no-console 12 | } 13 | } 14 | 15 | componentDidUpdate() { 16 | const { manager } = this.props; 17 | if (manager) { 18 | manager._restoreWindow(); 19 | } 20 | } 21 | 22 | render() { 23 | return this.props.children; 24 | } 25 | } 26 | 27 | ManagedWindowScroller.propTypes = { 28 | manager: PropTypes.instanceOf(ScrollManager).isRequired, 29 | children: PropTypes.node 30 | }; 31 | 32 | export const WindowScroller = withManager(ManagedWindowScroller); 33 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { History } from 'history'; 3 | 4 | export interface ScrollManagerProps { 5 | history: History; 6 | sessionKey?: string; 7 | timeout?: number; 8 | } 9 | 10 | export class ScrollManager extends React.Component { } 11 | 12 | export class WindowScroller extends React.Component { } 13 | 14 | export interface ElementScrollerProps { 15 | scrollKey: string; 16 | } 17 | 18 | export class ElementScroller extends React.Component { } 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { ScrollManager } from './ScrollManager'; 2 | export { WindowScroller } from './WindowScroller'; 3 | export { ElementScroller } from './ElementScroller'; 4 | -------------------------------------------------------------------------------- /src/timedMutationObserver.js: -------------------------------------------------------------------------------- 1 | let observeMutations, disconnectObserver; 2 | 3 | // fall back to polling if MutationObserver is not available 4 | if (window.MutationObserver) { 5 | observeMutations = (callback, _timeout, node, config) => { 6 | const observer = new MutationObserver(callback); 7 | observer.observe(node, config); 8 | return observer; 9 | } 10 | disconnectObserver = (observer) => { 11 | observer.disconnect(); 12 | } 13 | } else { 14 | observeMutations = (callback, timeout) => { 15 | return setInterval(callback, Math.min(timeout, 500)); 16 | } 17 | disconnectObserver = (observer) => { 18 | clearInterval(observer); 19 | } 20 | } 21 | 22 | const defaultObserverConfig = { 23 | attributes: true, 24 | childList: true, 25 | subtree: true 26 | }; 27 | 28 | export default function timedMutationObserver(callback, timeout, node = document, observerConfig = defaultObserverConfig) { 29 | let cancel; 30 | const result = new Promise((resolve, reject) => { 31 | let observer; 32 | let timeoutId; 33 | let success; 34 | 35 | cancel = () => { 36 | disconnectObserver(observer); 37 | clearTimeout(timeoutId); 38 | if (!success) { 39 | const reason = new Error('MutationObserver cancelled'); 40 | reason.cancelled = true; 41 | reason.timedOut = false; 42 | reject(reason); 43 | } 44 | }; 45 | 46 | observer = observeMutations(() => { 47 | if (!success && (success = callback())) { 48 | cancel(); 49 | resolve(success); 50 | } 51 | }, timeout, node, observerConfig); 52 | 53 | timeoutId = setTimeout(() => { 54 | disconnectObserver(observer); 55 | clearTimeout(timeoutId); 56 | if (!success) { 57 | const reason = new Error('MutationObserver timed out'); 58 | reason.cancelled = false; 59 | reason.timedOut = true; 60 | reject(reason); 61 | } 62 | }, timeout); 63 | }); 64 | result.cancel = cancel; 65 | return result; 66 | } 67 | -------------------------------------------------------------------------------- /test/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | jest: true 3 | -------------------------------------------------------------------------------- /test/ScrollManager.default.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router } from 'react-router-dom'; 3 | import renderer from 'react-test-renderer'; 4 | import { createMemoryHistory as createHistory } from 'history'; 5 | import { ScrollManager, WindowScroller, ElementScroller } from '../src'; 6 | import { mockElement, setScrollLeft, setScrollTop } from './mocks'; 7 | 8 | test('Default positioning', () => { 9 | const history = createHistory(); 10 | const tree = renderer.create( 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 |
, 20 | { 21 | createNodeMock() { 22 | return mockElement; 23 | } 24 | } 25 | ); 26 | 27 | expect(tree.toJSON()).toEqual({ type: 'div', props: {}, children: null }); 28 | expect(window.scrollTo.mock.calls).toEqual([[0, 0]]); 29 | expect(setScrollLeft.mock.calls).toEqual([[0]]); 30 | expect(setScrollTop.mock.calls).toEqual([[0]]); 31 | }); 32 | -------------------------------------------------------------------------------- /test/ScrollManager.stored.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router } from 'react-router-dom'; 3 | import renderer from 'react-test-renderer'; 4 | import { createMemoryHistory as createHistory } from 'history'; 5 | import { ScrollManager, WindowScroller, ElementScroller } from '../src'; 6 | import { mockElement, setScrollLeft, setScrollTop } from './mocks'; 7 | 8 | const locationKey = 'abcdef'; 9 | window.sessionStorage.setItem('scroll', JSON.stringify({ 10 | positions: { 11 | [locationKey]: { 12 | window: { 13 | scrollX: 10, 14 | scrollY: 20 15 | }, 16 | main: { 17 | scrollLeft: 30, 18 | scrollTop: 40 19 | } 20 | } 21 | }, 22 | locations: [locationKey], 23 | locationKey 24 | })); 25 | 26 | test('Stored positioning', () => { 27 | const history = createHistory(); 28 | const tree = renderer.create( 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 |
, 38 | { 39 | createNodeMock() { 40 | return mockElement; 41 | } 42 | } 43 | ); 44 | 45 | expect(tree.toJSON()).toEqual({ type: 'div', props: {}, children: null }); 46 | expect(window.scrollTo.mock.calls).toEqual([[10, 20]]); 47 | expect(setScrollLeft.mock.calls).toEqual([[30]]); 48 | expect(setScrollTop.mock.calls).toEqual([[40]]); 49 | }); 50 | -------------------------------------------------------------------------------- /test/mocks.js: -------------------------------------------------------------------------------- 1 | window.scrollTo = jest.fn(); 2 | 3 | export const getScrollLeft = jest.fn(() => 0); 4 | export const setScrollLeft = jest.fn(); 5 | export const getScrollTop = jest.fn(() => 0); 6 | export const setScrollTop = jest.fn(); 7 | export const mockElement = Object.create(null, { 8 | scrollLeft: { 9 | enumerable: true, 10 | get: getScrollLeft, 11 | set: setScrollLeft 12 | }, 13 | scrollTop: { 14 | enumerable: true, 15 | get: getScrollTop, 16 | set: setScrollTop 17 | } 18 | }); 19 | --------------------------------------------------------------------------------