├── .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 | [](https://travis-ci.org/trevorr/react-scroll-manager)
4 | [](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 |
--------------------------------------------------------------------------------