From 61dba5130c27e44fc670af308307227e0a14b73b Mon Sep 17 00:00:00 2001 From: Jimmy Jia Date: Fri, 4 Nov 2016 18:30:29 -0400 Subject: [PATCH] Remove history integration - Expose only the ScrollBehavior class - Remove explicit history dependencies --- .babelrc | 4 +- .eslintrc | 8 +- README.md | 80 ++++-- karma.conf.js | 4 +- package.json | 51 ++-- src/ScrollBehavior.js | 248 ------------------ src/index.js | 248 +++++++++++++++--- test/.eslintrc | 6 +- ...hScroll.test.js => ScrollBehavior.test.js} | 67 +---- test/histories.js | 8 + test/index.js | 4 + test/{fixtures.js => routes.js} | 13 +- test/run.js | 2 +- test/withScroll.js | 88 +++++++ tools/es2015Preset.js | 12 - tools/latestPreset.js | 14 + 16 files changed, 424 insertions(+), 433 deletions(-) delete mode 100644 src/ScrollBehavior.js rename test/{withScroll.test.js => ScrollBehavior.test.js} (72%) create mode 100644 test/histories.js rename test/{fixtures.js => routes.js} (88%) create mode 100644 test/withScroll.js delete mode 100644 tools/es2015Preset.js create mode 100644 tools/latestPreset.js diff --git a/.babelrc b/.babelrc index d077212..8a1285f 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,7 @@ { "presets": [ - "./tools/es2015Preset", - "stage-1" + "./tools/latestPreset", + "stage-2" ], "plugins": ["dev-expression"], diff --git a/.eslintrc b/.eslintrc index f383a35..5a7859e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,12 +1,6 @@ { - "extends": "airbnb-base", - "parser": "babel-eslint", + "extends": "4catalyzer", "env": { "browser": true - }, - "rules": { - "max-len": [2, 79, { - "ignorePattern": " // eslint-disable-line " - }] } } diff --git a/README.md b/README.md index 30b2546..f2cee9c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # scroll-behavior [![Travis][build-badge]][build] [![npm][npm-badge]][npm] -Scroll management for [`history`](https://github.com/ReactTraining/history). +Pluggable browser scroll behavior management. -**If you are using [React Router](https://github.com/reactjs/react-router), check out [react-router-scroll](https://github.com/taion/react-router-scroll), which wraps up the scroll management logic here into a router middleware.** +**If you use [React Router](https://github.com/reactjs/react-router), use [react-router-scroll](https://github.com/taion/react-router-scroll), which wraps up the scroll behavior management logic here into a React Router middleware.** [![Codecov][codecov-badge]][codecov] [![Discord][discord-badge]][discord] @@ -10,10 +10,19 @@ Scroll management for [`history`](https://github.com/ReactTraining/history). ## Usage ```js -import createHistory from 'history/lib/createBrowserHistory'; -import withScroll from 'scroll-behavior'; +import ScrollBehavior from 'scroll-behavior'; -const history = withScroll(createHistory()); +/* ... */ + +const scrollBehavior = new ScrollBehavior({ + addTransitionHook, + stateStorage, + getCurrentLocation, + /* shouldUpdateScroll, */ +}); + +// After a transition: +scrollBehavior.updateScroll(/* prevContext, context */); ``` ## Guide @@ -21,54 +30,67 @@ const history = withScroll(createHistory()); ### Installation ``` -$ npm i -S history $ npm i -S scroll-behavior ``` -### Scroll behaviors - ### Basic usage -Extend your history object using `withScroll`. The extended history object will manage the scroll position for transitions. +Create a `ScrollBehavior` object with the following arguments: +- `addTransitionHook`: this function should take a transition hook function and return an unregister function + - The transition hook function should be called immediately before a transition updates the page + - The unregister function should remove the transition hook when called +- `stateStorage`: this object should implement `read` and `save` methods + - 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 + - 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 +- `getCurrentLocation`: this function should return the current location object + +This object will keep track of the scroll position. Call the `updateScroll` method on this object after transitions to emulate the default browser scroll behavior on page changes. + +Call the `stop` method to tear down all listeners. ### Custom scroll behavior -You can customize the scroll behavior by providing a `shouldUpdateScroll` callback when extending the history object. This callback is called with both the previous location and the current location. +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. + +The callback can return: -You can return: +- a falsy value to suppress updating the scroll position +- a position array of `x` and `y`, such as `[0, 100]`, to scroll to that position +- a truthy value to emulate the browser default scroll behavior -- a falsy value to suppress the scroll update -- a position array such as `[0, 100]` to scroll to that position -- a truthy value to get normal scroll behavior +Assuming we call `updateScroll` with the previous and current location objects: ```js -const history = withScroll(createHistory(), (prevLocation, location) => ( - // Don't scroll if the pathname is the same. - !prevLocation || location.pathname !== prevLocation.pathname -)); +const scrollBehavior = new ScrollBehavior({ + ...options, + shouldUpdateScroll: (prevLocation, location) => ( + // Don't scroll if the pathname is the same. + !prevLocation || location.pathname !== prevLocation.pathname + ), +}); ``` ```js -const history = withScroll(createHistory(), (prevLocation, location) => ( - // Scroll to top when attempting to vist the current path. - prevLocation && location.pathname === prevLocation.pathname ? [0, 0] : true -)); +const scrollBehavior = new ScrollBehavior({ + ...options, + shouldUpdateScroll: (prevLocation, location) => ( + // Scroll to top when attempting to vist the current path. + prevLocation && location.pathname === prevLocation.pathname ? [0, 0] : true + ), +}); ``` ### Scrolling elements other than `window` -The `withScroll`-extended history object has a `registerScrollElement` method. This method registers an element other than `window` to have managed scroll behavior on transitions. 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. +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. ```js -const history = withScroll(createHistory(), () => false); -history.listen(listener); - -history.registerScrollElement( - key, element, shouldUpdateScroll +scrollBehavior.registerScrollElement( + key, element, shouldUpdateScroll, context, ); ``` -The `registerScrollElement` method returns an `unregister` function that you can use to explicitly unregister the scroll behavior on the element, if necessary. In general, you will not need to do this, as `withScroll` will perform all necessary cleanup on removal of the last history listener. +To unregister an element, call the `unregisterElement` method with the key used to register that element. [build-badge]: https://img.shields.io/travis/taion/scroll-behavior/master.svg [build]: https://travis-ci.org/taion/scroll-behavior diff --git a/karma.conf.js b/karma.conf.js index 4d4405b..94d4f69 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,10 +1,10 @@ const webpack = require('webpack'); // eslint-disable-line import/no-extraneous-dependencies -module.exports = config => { +module.exports = (config) => { const { env } = process; config.set({ - frameworks: ['mocha'], + frameworks: ['mocha', 'sinon-chai'], files: ['test/index.js'], diff --git a/package.json b/package.json index 2db23c2..c4f77bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "scroll-behavior", "version": "0.8.2", - "description": "Scroll management for history", + "description": "Pluggable browser scroll behavior management", "files": [ "es", "lib" @@ -24,8 +24,6 @@ "url": "git+https://github.com/taion/scroll-behavior.git" }, "keywords": [ - "history", - "location", "scroll" ], "author": "Jimmy Jia", @@ -35,40 +33,41 @@ }, "homepage": "https://github.com/taion/scroll-behavior#readme", "dependencies": { - "dom-helpers": "^2.4.0", + "dom-helpers": "^3.0.0", "invariant": "^2.2.1" }, - "peerDependencies": { - "history": "^1.12.1 || ^2.0.0" - }, "devDependencies": { - "babel-cli": "^6.11.4", - "babel-core": "^6.13.2", - "babel-eslint": "^6.1.2", - "babel-loader": "^6.2.4", + "babel-cli": "^6.18.0", + "babel-core": "^6.18.2", + "babel-eslint": "^7.1.0", + "babel-loader": "^6.2.7", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-dev-expression": "^0.2.1", - "babel-plugin-istanbul": "^1.0.3", - "babel-polyfill": "^6.13.0", - "babel-preset-es2015": "^6.13.2", - "babel-preset-stage-1": "^6.13.0", + "babel-plugin-istanbul": "^2.0.3", + "babel-polyfill": "^6.16.0", + "babel-preset-latest": "^6.16.0", + "babel-preset-stage-2": "^6.18.0", "chai": "^3.5.0", "codecov": "^1.0.1", - "cross-env": "^2.0.0", - "eslint": "^3.2.2", - "eslint-config-airbnb-base": "^5.0.1", - "eslint-plugin-import": "^1.12.0", + "cross-env": "^3.1.3", + "dirty-chai": "^1.2.2", + "eslint": "^3.9.1", + "eslint-config-4catalyzer": "^0.1.3", + "eslint-plugin-import": "^1.16.0", "history": "^2.1.2", - "karma": "^1.1.2", - "karma-chrome-launcher": "^1.0.1", + "karma": "^1.3.0", + "karma-chrome-launcher": "^2.0.0", "karma-coverage": "^1.1.1", "karma-firefox-launcher": "^1.0.0", - "karma-mocha": "^1.1.1", - "karma-mocha-reporter": "^2.1.0", + "karma-mocha": "^1.2.0", + "karma-mocha-reporter": "^2.2.0", + "karma-sinon-chai": "^1.2.4", "karma-sourcemap-loader": "^0.3.7", - "karma-webpack": "^1.7.0", - "mocha": "^3.0.1", + "karma-webpack": "^1.8.0", + "mocha": "^3.1.2", "rimraf": "^2.5.4", - "webpack": "^1.13.1" + "sinon": "^1.17.6", + "sinon-chai": "^2.8.0", + "webpack": "^1.13.3" } } diff --git a/src/ScrollBehavior.js b/src/ScrollBehavior.js deleted file mode 100644 index 1f1af6f..0000000 --- a/src/ScrollBehavior.js +++ /dev/null @@ -1,248 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import off from 'dom-helpers/events/off'; -import on from 'dom-helpers/events/on'; -import scrollLeft from 'dom-helpers/query/scrollLeft'; -import scrollTop from 'dom-helpers/query/scrollTop'; -import requestAnimationFrame from 'dom-helpers/util/requestAnimationFrame'; -import { PUSH } from 'history/lib/Actions'; -import { readState, saveState } from 'history/lib/DOMStateStorage'; -import invariant from 'invariant'; - -// FIXME: Stop using this gross hack. This won't collide with any actual -// history location keys, but it's dirty to sneakily use the same storage here. -const KEY_PREFIX = 's/'; - -// Try at most this many times to scroll, to avoid getting stuck. -const MAX_SCROLL_ATTEMPTS = 2; - -export default class ScrollBehavior { - constructor(history, getCurrentLocation, shouldUpdateScroll) { - this._history = history; - this._getCurrentLocation = getCurrentLocation; - this._shouldUpdateScroll = shouldUpdateScroll; - - // This helps avoid some jankiness in fighting against the browser's - // default scroll behavior on `POP` transitions. - /* istanbul ignore if: not supported by any browsers on Travis */ - if ('scrollRestoration' in window.history) { - this._oldScrollRestoration = window.history.scrollRestoration; - window.history.scrollRestoration = 'manual'; - } else { - this._oldScrollRestoration = null; - } - - this._saveWindowPositionHandle = null; - this._checkWindowScrollHandle = null; - this._windowScrollTarget = null; - this._numWindowScrollAttempts = 0; - - this._scrollElements = {}; - - // We have to listen to each window scroll update rather than to just - // location updates, because some browsers will update scroll position - // before emitting the location change. - on(window, 'scroll', this._onWindowScroll); - - this._unlistenBefore = history.listenBefore(() => { - if (this._saveWindowPositionHandle !== null) { - requestAnimationFrame.cancel(this._saveWindowPositionHandle); - this._saveWindowPositionHandle = null; - } - - // It's fine to save element scroll positions here, though; the browser - // won't modify them. - Object.keys(this._scrollElements).forEach(key => { - this._saveElementPosition(key); - }); - }); - } - - stop() { - /* istanbul ignore if: not supported by any browsers on Travis */ - if (this._oldScrollRestoration) { - window.history.scrollRestoration = this._oldScrollRestoration; - } - - off(window, 'scroll', this._onWindowScroll); - this._cancelCheckWindowScroll(); - - this._unlistenBefore(); - } - - registerElement(key, element, shouldUpdateScroll, context) { - invariant( - !this._scrollElements[key], - 'ScrollBehavior: There is already an element registered for `%s`.', - key - ); - - this._scrollElements[key] = { element, shouldUpdateScroll }; - this._updateElementScroll(key, null, context); - } - - unregisterElement(key) { - invariant( - this._scrollElements[key], - 'ScrollBehavior: There is no element registered for `%s`.', - key - ); - - delete this._scrollElements[key]; - } - - updateScroll(prevContext, context) { - this._updateWindowScroll(prevContext, context); - - Object.keys(this._scrollElements).forEach(key => { - this._updateElementScroll(key, prevContext, context); - }); - } - - readPosition(location, key) { - return readState(this._getKey(location, key)); - } - - _onWindowScroll = () => { - // It's possible that this scroll operation was triggered by what will be a - // `POP` transition. Instead of updating the saved location immediately, we - // have to enqueue the update, then potentially cancel it if we observe a - // location update. - if (this._saveWindowPositionHandle === null) { - this._saveWindowPositionHandle = - requestAnimationFrame(this._saveWindowPosition); - } - - if (this._windowScrollTarget) { - const [xTarget, yTarget] = this._windowScrollTarget; - const x = scrollLeft(window); - const y = scrollTop(window); - - if (x === xTarget && y === yTarget) { - this._windowScrollTarget = null; - this._cancelCheckWindowScroll(); - } - } - }; - - _saveWindowPosition = () => { - this._saveWindowPositionHandle = null; - - this._savePosition(null, window); - }; - - _cancelCheckWindowScroll() { - if (this._checkWindowScrollHandle !== null) { - requestAnimationFrame.cancel(this._checkWindowScrollHandle); - this._checkWindowScrollHandle = null; - } - } - - _saveElementPosition(key) { - const { element } = this._scrollElements[key]; - - this._savePosition(key, element); - } - - _savePosition(key, element) { - // We have to directly update `DOMStateStorage`, because actually updating - // the location could cause e.g. React Router to re-render the entire page, - // which would lead to observably bad scroll performance. - saveState( - this._getKey(this._getCurrentLocation(), key), - [scrollLeft(element), scrollTop(element)] - ); - } - - _getKey(location, key) { - // Use fallback location key when actual location key is unavailable. - const locationKey = location.key || this._history.createHref(location); - - return key == null ? - `${KEY_PREFIX}${locationKey}` : - `${KEY_PREFIX}${key}/${locationKey}`; - } - - _updateWindowScroll(prevContext, context) { - // Whatever we were doing before isn't relevant any more. - this._cancelCheckWindowScroll(); - - this._windowScrollTarget = this._getScrollTarget( - null, this._shouldUpdateScroll, prevContext, context - ); - - // Check the scroll position to see if we even need to scroll. This call - // will unset _windowScrollTarget if the current scroll position matches - // the target. - this._onWindowScroll(); - - if (!this._windowScrollTarget) { - return; - } - - // Updating the window scroll position is really flaky. Just trying to - // scroll it isn't enough. Instead, try to scroll a few times until it - // works. - this._numWindowScrollAttempts = 0; - this._checkWindowScrollPosition(); - } - - _updateElementScroll(key, prevContext, context) { - const { element, shouldUpdateScroll } = this._scrollElements[key]; - - const scrollTarget = this._getScrollTarget( - key, shouldUpdateScroll, prevContext, context - ); - if (!scrollTarget) { - return; - } - - // Unlike with the window, there shouldn't be any flakiness to deal with - // here. - const [x, y] = scrollTarget; - scrollLeft(element, x); - scrollTop(element, y); - } - - _getScrollTarget(key, shouldUpdateScroll, prevContext, context) { - const scrollTarget = shouldUpdateScroll ? - shouldUpdateScroll.call(this, prevContext, context) : true; - - if (!scrollTarget || Array.isArray(scrollTarget)) { - return scrollTarget; - } - - const location = this._getCurrentLocation(); - if (location.action === PUSH) { - return [0, 0]; - } - - return this.readPosition(location, key) || [0, 0]; - } - - _checkWindowScrollPosition = () => { - this._checkWindowScrollHandle = null; - - // We can only get here if scrollTarget is set. Every code path that unsets - // scroll target also cancels the handle to avoid calling this handler. - // Still, check anyway just in case. - /* istanbul ignore if: paranoid guard */ - if (!this._windowScrollTarget) { - return; - } - - const [x, y] = this._windowScrollTarget; - window.scrollTo(x, y); - - ++this._numWindowScrollAttempts; - - /* istanbul ignore if: paranoid guard */ - if (this._numWindowScrollAttempts >= MAX_SCROLL_ATTEMPTS) { - this._windowScrollTarget = null; - return; - } - - this._checkWindowScrollHandle = - requestAnimationFrame(this._checkWindowScrollPosition); - }; -} diff --git a/src/index.js b/src/index.js index f880652..086d6f5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,62 +1,232 @@ -import ScrollBehavior from './ScrollBehavior'; +/* eslint-disable no-underscore-dangle */ -export default function withScroll(history, shouldUpdateScroll) { - // history will invoke the onChange callback synchronously, so - // currentLocation will always be defined when needed. - let currentLocation = null; +import off from 'dom-helpers/events/off'; +import on from 'dom-helpers/events/on'; +import scrollLeft from 'dom-helpers/query/scrollLeft'; +import scrollTop from 'dom-helpers/query/scrollTop'; +import requestAnimationFrame from 'dom-helpers/util/requestAnimationFrame'; +import invariant from 'invariant'; - function getCurrentLocation() { - return currentLocation; +// Try at most this many times to scroll, to avoid getting stuck. +const MAX_SCROLL_ATTEMPTS = 2; + +export default class ScrollBehavior { + constructor({ + addTransitionHook, + stateStorage, + getCurrentLocation, + shouldUpdateScroll, + }) { + this._stateStorage = stateStorage; + this._getCurrentLocation = getCurrentLocation; + this._shouldUpdateScroll = shouldUpdateScroll; + + // This helps avoid some jankiness in fighting against the browser's + // default scroll behavior on `POP` transitions. + /* istanbul ignore if: not supported by any browsers on Travis */ + if ('scrollRestoration' in window.history) { + this._oldScrollRestoration = window.history.scrollRestoration; + window.history.scrollRestoration = 'manual'; + } else { + this._oldScrollRestoration = null; + } + + this._saveWindowPositionHandle = null; + this._checkWindowScrollHandle = null; + this._windowScrollTarget = null; + this._numWindowScrollAttempts = 0; + + this._scrollElements = {}; + + // We have to listen to each window scroll update rather than to just + // location updates, because some browsers will update scroll position + // before emitting the location change. + on(window, 'scroll', this._onWindowScroll); + + this._removeTransitionHook = addTransitionHook(() => { + if (this._saveWindowPositionHandle !== null) { + requestAnimationFrame.cancel(this._saveWindowPositionHandle); + this._saveWindowPositionHandle = null; + } + + // It's fine to save element scroll positions here, though; the browser + // won't modify them. + Object.keys(this._scrollElements).forEach((key) => { + this._saveElementPosition(key); + }); + }); } - let listeners = []; - let scrollBehavior = null; + registerElement(key, element, shouldUpdateScroll, context) { + invariant( + !this._scrollElements[key], + 'ScrollBehavior: There is already an element registered for `%s`.', + key + ); - function onChange(location) { - const prevLocation = currentLocation; - currentLocation = location; + this._scrollElements[key] = { element, shouldUpdateScroll }; + this._updateElementScroll(key, null, context); + } - listeners.forEach(listener => listener(location)); + unregisterElement(key) { + invariant( + this._scrollElements[key], + 'ScrollBehavior: There is no element registered for `%s`.', + key + ); - scrollBehavior.updateScroll(prevLocation, location); + delete this._scrollElements[key]; } - let unlisten = null; + updateScroll(prevContext, context) { + this._updateWindowScroll(prevContext, context); + + Object.keys(this._scrollElements).forEach((key) => { + this._updateElementScroll(key, prevContext, context); + }); + } - function listen(listener) { - if (listeners.length === 0) { - scrollBehavior = new ScrollBehavior( - history, getCurrentLocation, shouldUpdateScroll - ); - unlisten = history.listen(onChange); + stop() { + /* istanbul ignore if: not supported by any browsers on Travis */ + if (this._oldScrollRestoration) { + window.history.scrollRestoration = this._oldScrollRestoration; } - listeners.push(listener); - listener(currentLocation); + off(window, 'scroll', this._onWindowScroll); + this._cancelCheckWindowScroll(); - return () => { - listeners = listeners.filter(item => item !== listener); + this._removeTransitionHook(); + } + + _onWindowScroll = () => { + // It's possible that this scroll operation was triggered by what will be a + // `POP` transition. Instead of updating the saved location immediately, we + // have to enqueue the update, then potentially cancel it if we observe a + // location update. + if (this._saveWindowPositionHandle === null) { + this._saveWindowPositionHandle = + requestAnimationFrame(this._saveWindowPosition); + } + + if (this._windowScrollTarget) { + const [xTarget, yTarget] = this._windowScrollTarget; + const x = scrollLeft(window); + const y = scrollTop(window); - if (listeners.length === 0) { - scrollBehavior.stop(); - unlisten(); + if (x === xTarget && y === yTarget) { + this._windowScrollTarget = null; + this._cancelCheckWindowScroll(); } - }; + } + }; + + _saveWindowPosition = () => { + this._saveWindowPositionHandle = null; + + this._savePosition(null, window); + }; + + _cancelCheckWindowScroll() { + if (this._checkWindowScrollHandle !== null) { + requestAnimationFrame.cancel(this._checkWindowScrollHandle); + this._checkWindowScrollHandle = null; + } + } + + _saveElementPosition(key) { + const { element } = this._scrollElements[key]; + + this._savePosition(key, element); } - function registerScrollElement(key, element, shouldUpdateElementScroll) { - scrollBehavior.registerElement( - key, element, shouldUpdateElementScroll, currentLocation + _savePosition(key, element) { + this._stateStorage.save( + this._getCurrentLocation(), + key, + [scrollLeft(element), scrollTop(element)], ); + } + + _updateWindowScroll(prevContext, context) { + // Whatever we were doing before isn't relevant any more. + this._cancelCheckWindowScroll(); + + this._windowScrollTarget = this._getScrollTarget( + null, this._shouldUpdateScroll, prevContext, context, + ); + + // Check the scroll position to see if we even need to scroll. This call + // will unset _windowScrollTarget if the current scroll position matches + // the target. + this._onWindowScroll(); + + if (!this._windowScrollTarget) { + return; + } - return () => { - scrollBehavior.unregisterElement(key); - }; + // Updating the window scroll position is really flaky. Just trying to + // scroll it isn't enough. Instead, try to scroll a few times until it + // works. + this._numWindowScrollAttempts = 0; + this._checkWindowScrollPosition(); } - return { - ...history, - listen, - registerScrollElement, + _updateElementScroll(key, prevContext, context) { + const { element, shouldUpdateScroll } = this._scrollElements[key]; + + const scrollTarget = this._getScrollTarget( + key, shouldUpdateScroll, prevContext, context, + ); + if (!scrollTarget) { + return; + } + + // Unlike with the window, there shouldn't be any flakiness to deal with + // here. + const [x, y] = scrollTarget; + scrollLeft(element, x); + scrollTop(element, y); + } + + _getScrollTarget(key, shouldUpdateScroll, prevContext, context) { + const scrollTarget = shouldUpdateScroll ? + shouldUpdateScroll.call(this, prevContext, context) : true; + + if (!scrollTarget || Array.isArray(scrollTarget)) { + return scrollTarget; + } + + const location = this._getCurrentLocation(); + if (location.action === 'PUSH') { + return [0, 0]; + } + + return this._stateStorage.read(location, key) || [0, 0]; + } + + _checkWindowScrollPosition = () => { + this._checkWindowScrollHandle = null; + + // We can only get here if scrollTarget is set. Every code path that unsets + // scroll target also cancels the handle to avoid calling this handler. + // Still, check anyway just in case. + /* istanbul ignore if: paranoid guard */ + if (!this._windowScrollTarget) { + return; + } + + const [x, y] = this._windowScrollTarget; + window.scrollTo(x, y); + + ++this._numWindowScrollAttempts; + + /* istanbul ignore if: paranoid guard */ + if (this._numWindowScrollAttempts >= MAX_SCROLL_ATTEMPTS) { + this._windowScrollTarget = null; + return; + } + + this._checkWindowScrollHandle = + requestAnimationFrame(this._checkWindowScrollPosition); }; } diff --git a/test/.eslintrc b/test/.eslintrc index b31d252..aa17fe1 100644 --- a/test/.eslintrc +++ b/test/.eslintrc @@ -2,9 +2,11 @@ "env": { "mocha": true }, + "globals": { + "expect": false + }, "rules": { - "no-unused-expressions": 0, - "import/no-extraneous-dependencies": [2, { + "import/no-extraneous-dependencies": ["error", { "devDependencies": true }] } diff --git a/test/withScroll.test.js b/test/ScrollBehavior.test.js similarity index 72% rename from test/withScroll.test.js rename to test/ScrollBehavior.test.js index c8ce462..6bd1c88 100644 --- a/test/withScroll.test.js +++ b/test/ScrollBehavior.test.js @@ -1,25 +1,20 @@ -import { expect } from 'chai'; import scrollLeft from 'dom-helpers/query/scrollLeft'; import scrollTop from 'dom-helpers/query/scrollTop'; import createBrowserHistory from 'history/lib/createBrowserHistory'; import createHashHistory from 'history/lib/createHashHistory'; -import withScroll from '../src'; - -import { - createHashHistoryWithoutKey, - withRoutes, - withScrollElement, - withScrollElementRoutes, -} from './fixtures'; +import { createHashHistoryWithoutKey } from './histories'; +import { withRoutes, withScrollElement, withScrollElementRoutes } + from './routes'; import run, { delay } from './run'; +import withScroll from './withScroll'; -describe('withScroll', () => { +describe('ScrollBehavior', () => { [ createBrowserHistory, createHashHistory, createHashHistoryWithoutKey, - ].forEach(createHistory => { + ].forEach((createHistory) => { describe(createHistory.name, () => { let unlisten; @@ -30,7 +25,7 @@ describe('withScroll', () => { }); describe('default behavior', () => { - it('should emulate browser scroll behavior', done => { + it('should emulate browser scroll behavior', (done) => { const history = withRoutes(withScroll(createHistory())); unlisten = run(history, [ @@ -48,8 +43,8 @@ describe('withScroll', () => { scrollTop(window, 5000); delay(history.goBack); }, - location => { - expect(location.state).to.not.exist; + (location) => { + expect(location.state).to.not.exist(); expect(scrollTop(window)).to.equal(15000); history.push('/detail'); }, @@ -62,7 +57,7 @@ describe('withScroll', () => { }); describe('custom behavior', () => { - it('should allow scroll suppression', done => { + it('should allow scroll suppression', (done) => { const history = withRoutes( withScroll( createHistory(), @@ -91,7 +86,7 @@ describe('withScroll', () => { ]); }); - it('should allow custom position', done => { + it('should allow custom position', (done) => { const history = withRoutes(withScroll( createHistory(), () => [10, 20] )); @@ -110,46 +105,10 @@ describe('withScroll', () => { }, ]); }); - - it('should allow reading position', done => { - let prevPosition; - let position; - - function shouldUpdateScroll(prevLocation, location) { - if (prevLocation) { - prevPosition = this.readPosition(prevLocation); - } - - position = this.readPosition(location); - - return true; - } - - const history = withRoutes(withScroll( - createHistory(), shouldUpdateScroll - )); - - unlisten = run(history, [ - () => { - scrollTop(window, 15000); - delay(() => history.push('/detail')); - }, - () => { - expect(prevPosition).to.eql([0, 15000]); - expect(position).to.not.exist; - history.goBack(); - }, - () => { - expect(prevPosition).to.eql([0, 0]); - expect(position).to.eql([0, 15000]); - done(); - }, - ]); - }); }); describe('scroll element', () => { - it('should follow browser scroll behavior', done => { + it('should follow browser scroll behavior', (done) => { const { container, ...history } = withScrollElement( withScroll(createHistory(), () => false) ); @@ -175,7 +134,7 @@ describe('withScroll', () => { ]); }); - it('should restore scroll on remount', done => { + it('should restore scroll on remount', (done) => { const { container, ...history } = withScrollElementRoutes( withScroll(createHistory(), () => false) ); diff --git a/test/histories.js b/test/histories.js new file mode 100644 index 0000000..ecd4c32 --- /dev/null +++ b/test/histories.js @@ -0,0 +1,8 @@ +import createHashHistory from 'history/lib/createHashHistory'; + +export function createHashHistoryWithoutKey() { + // Avoid persistence of stored data from previous tests. + window.sessionStorage.clear(); + + return createHashHistory({ queryKey: false }); +} diff --git a/test/index.js b/test/index.js index fade6c4..a511c0e 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,9 @@ import 'babel-polyfill'; +import dirtyChai from 'dirty-chai'; + +global.chai.use(dirtyChai); + // Ensure all files in src folder are loaded for proper code coverage analysis. const srcContext = require.context('../src', true, /.*\.js$/); srcContext.keys().forEach(srcContext); diff --git a/test/fixtures.js b/test/routes.js similarity index 88% rename from test/fixtures.js rename to test/routes.js index d43d83b..d2e6785 100644 --- a/test/fixtures.js +++ b/test/routes.js @@ -1,19 +1,10 @@ -import createHashHistory from 'history/lib/createHashHistory'; - -export function createHashHistoryWithoutKey() { - // Avoid persistence of stored data from previous tests. - window.sessionStorage.clear(); - - return createHashHistory({ queryKey: false }); -} - export function withRoutes(history) { const container = document.createElement('div'); document.body.appendChild(container); // This will only be called once, so no need to guard. function listen(listener) { - const unlisten = history.listen(location => { + const unlisten = history.listen((location) => { listener(location); if (location.pathname === '/') { @@ -91,7 +82,7 @@ export function withScrollElementRoutes(history) { return true; } - const unlisten = history.listen(location => { + const unlisten = history.listen((location) => { listener(location); if (location.pathname === '/') { diff --git a/test/run.js b/test/run.js index 3439912..47e3c1a 100644 --- a/test/run.js +++ b/test/run.js @@ -8,7 +8,7 @@ export default function run(history, steps) { let i = 0; - return history.listen(location => { + return history.listen((location) => { if (i === steps.length) { return; } diff --git a/test/withScroll.js b/test/withScroll.js new file mode 100644 index 0000000..f585e13 --- /dev/null +++ b/test/withScroll.js @@ -0,0 +1,88 @@ +import { readState, saveState } from 'history/lib/DOMStateStorage'; + +import ScrollBehavior from '../src'; + +class HistoryStateStorage { + constructor(history, namespace) { + this.getFallbackLocationKey = history.createPath; + this.stateKeyPrefix = `${namespace}|`; + } + + read(location, key) { + return readState(this.getStateKey(location, key)); + } + + save(location, key, value) { + saveState(this.getStateKey(location, key), value); + } + + getStateKey(location, key) { + const locationKey = location.key || this.getFallbackLocationKey(location); + const stateKeyBase = `${this.stateKeyPrefix}${locationKey}`; + return key == null ? stateKeyBase : `${stateKeyBase}|${key}`; + } +} + +export default function withScroll(history, shouldUpdateScroll) { + // history v2 will invoke the onChange callback synchronously, so + // currentLocation will always be defined when needed. + let currentLocation = null; + + function getCurrentLocation() { + return currentLocation; + } + + let listeners = []; + let scrollBehavior = null; + + function onChange(location) { + const prevLocation = currentLocation; + currentLocation = location; + + listeners.forEach((listener) => { listener(location); }); + + scrollBehavior.updateScroll(prevLocation, location); + } + + let unlisten = null; + + function listen(listener) { + if (listeners.length === 0) { + scrollBehavior = new ScrollBehavior({ + addTransitionHook: history.listenBefore, + stateStorage: new HistoryStateStorage(history, 'scroll'), + getCurrentLocation, + shouldUpdateScroll, + }); + unlisten = history.listen(onChange); + } + + listeners.push(listener); + listener(currentLocation); + + return () => { + listeners = listeners.filter(item => item !== listener); + + if (listeners.length === 0) { + scrollBehavior.stop(); + unlisten(); + } + }; + } + + function registerScrollElement(key, element, shouldUpdateElementScroll) { + scrollBehavior.registerElement( + key, element, shouldUpdateElementScroll, currentLocation + ); + + return () => { + scrollBehavior.unregisterElement(key); + }; + } + + return { + ...history, + listen, + registerScrollElement, + }; +} diff --git a/tools/es2015Preset.js b/tools/es2015Preset.js deleted file mode 100644 index 3414e69..0000000 --- a/tools/es2015Preset.js +++ /dev/null @@ -1,12 +0,0 @@ -const { buildPreset } = require('babel-preset-es2015'); - -const { BABEL_ENV } = process.env; - -module.exports = { - presets: [ - [buildPreset, { - loose: true, - modules: BABEL_ENV === 'es' ? false : 'commonjs', - }], - ], -}; diff --git a/tools/latestPreset.js b/tools/latestPreset.js new file mode 100644 index 0000000..c5984a0 --- /dev/null +++ b/tools/latestPreset.js @@ -0,0 +1,14 @@ +const babelPresetLatest = require('babel-preset-latest'); + +const { BABEL_ENV } = process.env; + +module.exports = { + presets: [ + [babelPresetLatest, { + es2015: { + loose: true, + modules: BABEL_ENV === 'es' ? false : 'commonjs', + }, + }], + ], +};