diff --git a/.babelrc b/.babelrc index 6e6a3fa..fad2bcb 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,11 @@ { - "stage": 1, - "loose": "all" + "env": { + "cjs": { + "presets": ["es2015-loose", "stage-1"], + "plugins": ["add-module-exports"] + }, + "es": { + "presets": ["es2015-loose-native-modules", "stage-1"] + } + } } diff --git a/.eslintrc b/.eslintrc index 8352d52..1463810 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,7 @@ { - "extends": "rackt" + "extends": "airbnb-base", + "parser": "babel-eslint", + "rules": { + "max-len": [2, 79] + } } diff --git a/README.md b/README.md index cba1ddd..7135a97 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,17 @@ # scroll-behavior [![Travis][build-badge]][build] [![npm][npm-badge]][npm] -Scroll behaviors for use with [`history`](https://github.com/reactjs/history). +Scroll management for [`history`](https://github.com/reactjs/history). [![Coveralls][coveralls-badge]][coveralls] [![Discord][discord-badge]][discord] ## Usage -Extend your history object with one of the scroll behaviors in this library to get the desired scroll behavior after transitions. - ```js -import createHistory from 'history/lib/createBrowserHistory' -import withScroll from 'scroll-behavior/lib/withStandardScroll' +import createHistory from 'history/lib/createBrowserHistory'; +import withScroll from 'scroll-behavior'; -const history = withScroll(createHistory()) +const history = withScroll(createHistory()); ``` ## Guide @@ -21,79 +19,48 @@ const history = withScroll(createHistory()) ### Installation ``` -$ npm install history scroll-behavior +$ npm i -S history +$ npm i -S scroll-behavior ``` -You may also want to install [React Router](https://github.com/reactjs/react-router) to obtain a complete routing solution for React that works with history. - ### Scroll behaviors -#### `withScrollToTop` - -`withScrollToTop` scrolls to the top of the page after any transition. - -This is not fully reliable for `POP` transitions. - -#### `withSimpleScroll` +### Basic usage -`withSimpleScroll` scrolls to the top of the page on `PUSH` and `REPLACE` transitions, while allowing the browser to manage scroll position for `POP` transitions. - -This can give pretty good results with synchronous transitions on browsers like Chrome that don't update the scroll position until after they've notified `history` of the location change. It will not work as well when using asynchronous transitions or with browsers like Firefox that update the scroll position before emitting the location change. - -#### `withStandardScroll` - -`withStandardScroll` attempts to imitate native browser scroll behavior by recording updates to the window scroll position, then restoring the previous scroll position upon a `POP` transition. +Extend your history object using `withScroll`. The extended history object will manage the scroll position for transitions. ### Custom behavior -You can further customize 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 extending the history object. This callback is called with both the previous location and the current location. You can return: - a falsy value to suppress the scroll update -- a position array such as `[ 0, 100 ]` to scroll to that position +- a position array such as `[0, 100]` to scroll to that position - a truthy value to get normal scroll behavior ```js const history = withScroll(createHistory(), (prevLocation, location) => ( // Don't scroll if the pathname is the same. location.pathname !== prevLocation.pathname -)) +)); ``` ```js const history = withScroll(createHistory(), (prevLocation, location) => ( // Scroll to top when attempting to vist the current path. - location.pathname === prevLocation.pathname ? [ 0, 0 ] : true -)) -``` - -### Async transitions - -If you are using async routes or async data loading, you may need to defer the update of the scroll position until the async transition is complete. You can do this by passing in a callback as the third argument to `shouldUpdateScroll`: - -```js -let updateScroll - -const history = withScroll(createHistory(), (prevLocation, location, cb) => { - updateScroll = cb -}) -``` - -After transition is finished, you can trigger the update of the scroll position by invoking the callback with the same value you would have returned from a synchronous `shouldUpdateScroll` function: - -```js -updateScroll(true) + location.pathname === prevLocation.pathname ? [0, 0] : true +)); ``` -[build-badge]: https://img.shields.io/travis/taion/scroll-behavior/master.svg?style=flat-square +[build-badge]: https://img.shields.io/travis/taion/scroll-behavior/master.svg [build]: https://travis-ci.org/taion/scroll-behavior -[npm-badge]: https://img.shields.io/npm/v/scroll-behavior.svg?style=flat-square +[npm-badge]: https://img.shields.io/npm/v/scroll-behavior.svg [npm]: https://www.npmjs.org/package/scroll-behavior -[coveralls-badge]: https://img.shields.io/coveralls/taion/scroll-behavior/master.svg?style=flat-square +[coveralls-badge]: https://img.shields.io/coveralls/taion/scroll-behavior/master.svg [coveralls]: https://coveralls.io/github/taion/scroll-behavior -[discord-badge]: https://img.shields.io/badge/Discord-join%20chat%20%E2%86%92-738bd7.svg?style=flat-square +[discord-badge]: https://img.shields.io/badge/Discord-join%20chat%20%E2%86%92-738bd7.svg [discord]: https://discord.gg/0ZcbPKXt5bYaNQ46 diff --git a/karma.conf.babel.js b/karma.conf.babel.js index 2893407..7c6d111 100644 --- a/karma.conf.babel.js +++ b/karma.conf.babel.js @@ -1,74 +1,77 @@ -import path from 'path' -import webpack from 'webpack' +import path from 'path'; +import webpack from 'webpack'; export default config => { - const { env } = process + const { env } = process; - const isCi = env.CONTINUOUS_INTEGRATION === 'true' - const runCoverage = env.COVERAGE === 'true' || isCi + const isCi = env.CONTINUOUS_INTEGRATION === 'true'; + const runCoverage = env.COVERAGE === 'true' || isCi; - const coverageLoaders = [] - const coverageReporters = [] + const coverageLoaders = []; + const coverageReporters = []; if (runCoverage) { coverageLoaders.push({ test: /\.js$/, include: path.resolve('modules/'), exclude: /__tests__/, - loader: 'isparta' - }) + loader: 'isparta', + }); - coverageReporters.push('coverage') + coverageReporters.push('coverage'); if (isCi) { - coverageReporters.push('coveralls') + coverageReporters.push('coveralls'); } } config.set({ - frameworks: [ 'mocha' ], + frameworks: ['mocha'], - files: [ 'tests.webpack.js' ], + files: ['./test/index.js'], preprocessors: { - 'tests.webpack.js': [ 'webpack', 'sourcemap' ] + './test/index.js': ['webpack', 'sourcemap'], }, webpack: { module: { loaders: [ { test: /\.js$/, exclude: /node_modules/, loader: 'babel' }, - ...coverageLoaders - ] + ...coverageLoaders, + ], }, plugins: [ new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('test') - }) + 'process.env.NODE_ENV': JSON.stringify('test'), + }), ], - devtool: 'inline-source-map' + devtool: 'inline-source-map', }, webpackMiddleware: { - noInfo: true + noInfo: true, }, - reporters: [ 'mocha', ...coverageReporters ], + reporters: [ + 'mocha', + ...coverageReporters, + ], coverageReporter: { type: 'lcov', - dir: 'coverage' + dir: 'coverage', }, customLaunchers: { ChromeCi: { base: 'Chrome', - flags: [ '--no-sandbox' ] - } + flags: ['--no-sandbox'], + }, }, - browsers: isCi ? [ env.BROWSER ] : [ 'Chrome', 'Firefox' ], + browsers: isCi ? [env.BROWSER] : ['Chrome', 'Firefox'], - singleRun: isCi - }) -} + singleRun: isCi, + }); +}; diff --git a/karma.conf.js b/karma.conf.js index c3a5a3f..05460d3 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,2 +1,2 @@ -require('babel-core/register') -module.exports = require('./karma.conf.babel.js') +require('babel-register'); +module.exports = require('./karma.conf.babel.js'); diff --git a/modules/__tests__/.eslintrc b/modules/__tests__/.eslintrc deleted file mode 100644 index 7eeefc3..0000000 --- a/modules/__tests__/.eslintrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "env": { - "mocha": true - } -} diff --git a/modules/__tests__/config.js b/modules/__tests__/config.js deleted file mode 100644 index 5db065d..0000000 --- a/modules/__tests__/config.js +++ /dev/null @@ -1,10 +0,0 @@ -import createBrowserHistory from 'history/lib/createBrowserHistory' -import createHashHistory from 'history/lib/createHashHistory' - -// Use a delay between steps to let things settle. -export const DELAY = 20 - -export const HISTORIES = [ - createBrowserHistory, - createHashHistory -] diff --git a/modules/__tests__/delay.js b/modules/__tests__/delay.js deleted file mode 100644 index 66807f7..0000000 --- a/modules/__tests__/delay.js +++ /dev/null @@ -1,6 +0,0 @@ -import { DELAY } from './config' - -export default function delay(cb) { - // Give throttled scroll listeners time to settle down. - requestAnimationFrame(() => setTimeout(cb, DELAY)) -} diff --git a/modules/__tests__/describeShouldUpdateScroll.js b/modules/__tests__/describeShouldUpdateScroll.js deleted file mode 100644 index d01987e..0000000 --- a/modules/__tests__/describeShouldUpdateScroll.js +++ /dev/null @@ -1,97 +0,0 @@ -import expect from 'expect' -import scrollLeft from 'dom-helpers/query/scrollLeft' -import scrollTop from 'dom-helpers/query/scrollTop' - -import delay from './delay' -import { withRoutes } from './fixtures' -import run from './run' - -export default function describeShouldUpdateScroll(withScroll, createHistory) { - describe('shouldUpdateScroll', () => { - let unlisten - - afterEach(done => { - if (unlisten) { - unlisten() - } - - delay(done) - }) - - it('should allow scroll suppression', done => { - const history = withRoutes(withScroll( - createHistory(), - (prevLocation, location) => ( - !prevLocation || prevLocation.pathname !== location.pathname - ) - )) - - unlisten = run(history, [ - () => { - history.push('/oldpath') - }, - () => { - scrollTop(window, 5000) - delay(() => history.push({ - pathname: '/oldpath', query: { key: 'value' } - })) - }, - () => { - expect(scrollTop(window)).toBe(5000) - history.push('/newpath') - }, - () => { - expect(scrollTop(window)).toBe(0) - done() - } - ]) - }) - - it('should allow custom position', done => { - const history = withRoutes(withScroll( - createHistory(), () => [ 10 , 20 ] - )) - - unlisten = run(history, [ - () => { - history.push('/oldpath') - }, - () => { - history.push('/newpath') - }, - () => { - expect(scrollLeft(window)).toBe(10) - expect(scrollTop(window)).toBe(20) - done() - } - ]) - }) - - it('should allow async transition', done => { - let shouldUpdateCb = null - const history = withRoutes(withScroll( - createHistory(), - (old, current, cb) => { shouldUpdateCb = cb } - )) - - unlisten = run(history, [ - () => { - history.push('/oldpath') - }, - () => { - history.push('/newpath') - }, - () => { - expect(scrollLeft(window)).toBe(0) - expect(scrollTop(window)).toBe(0) - shouldUpdateCb([ 10, 20 ]) - delay(() => { - expect(scrollLeft(window)).toBe(10) - expect(scrollTop(window)).toBe(20) - done() - }) - } - ]) - }) - }) -} diff --git a/modules/__tests__/fixtures.js b/modules/__tests__/fixtures.js deleted file mode 100644 index 3adda2c..0000000 --- a/modules/__tests__/fixtures.js +++ /dev/null @@ -1,29 +0,0 @@ -import withScroll from '../utils/withScroll' - -export function withRoutes(history) { - let element - - function getScrollPosition({ pathname }) { - if (pathname === '/') { - element.style.height = '20000px' - element.style.width = '20000px' - } else { - element.style.height = '10000px' - element.style.width = '10000px' - } - - // Force reflow. - element.offsetHeight - } - - function start() { - element = document.createElement('div') - document.body.appendChild(element) - } - - function stop() { - document.body.removeChild(element) - } - - return withScroll(history, null, { getScrollPosition, start, stop }) -} diff --git a/modules/__tests__/run.js b/modules/__tests__/run.js deleted file mode 100644 index e5efdbc..0000000 --- a/modules/__tests__/run.js +++ /dev/null @@ -1,17 +0,0 @@ -import { DELAY } from './config' - -export default function run(history, steps) { - window.history.replaceState(null, null, '/') - - let i = 0 - - return history.listen(() => { - if (i === steps.length) { - return - } - - // First wait a extra tick for all the scroll callbacks to fire before - // position, even if we don't need an extra delay. - setTimeout(steps[i++], DELAY) - }) -} diff --git a/modules/__tests__/withScrollToTop-test.js b/modules/__tests__/withScrollToTop-test.js deleted file mode 100644 index e700f8d..0000000 --- a/modules/__tests__/withScrollToTop-test.js +++ /dev/null @@ -1,61 +0,0 @@ -import expect from 'expect' -import scrollTop from 'dom-helpers/query/scrollTop' - -import withScrollToTop from '../withScrollToTop' - -import { HISTORIES } from './config' -import delay from './delay' -import describeShouldUpdateScroll from './describeShouldUpdateScroll' -import { withRoutes } from './fixtures' -import run from './run' - -describe('withScrollToTop', () => { - HISTORIES.forEach(createHistory => { - describe(createHistory.name, () => { - let history, unlisten - - beforeEach(() => { - history = withRoutes(withScrollToTop(createHistory())) - }) - - afterEach(done => { - if (unlisten) { - unlisten() - } - - delay(done) - }) - - it('should scroll to top on PUSH', done => { - unlisten = run(history, [ - () => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }, - () => { - expect(scrollTop(window)).toBe(0) - done() - } - ]) - }) - - it('should scroll to top on POP', done => { - unlisten = run(history, [ - () => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }, - () => { - history.goBack() - }, - () => { - expect(scrollTop(window)).toBe(0) - done() - } - ]) - }) - - describeShouldUpdateScroll(withScrollToTop, createHistory) - }) - }) -}) diff --git a/modules/__tests__/withSimpleScroll-test.js b/modules/__tests__/withSimpleScroll-test.js deleted file mode 100644 index efa5aed..0000000 --- a/modules/__tests__/withSimpleScroll-test.js +++ /dev/null @@ -1,61 +0,0 @@ -import expect from 'expect' -import scrollTop from 'dom-helpers/query/scrollTop' - -import withSimpleScroll from '../withSimpleScroll' - -import { HISTORIES } from './config' -import delay from './delay' -import describeShouldUpdateScroll from './describeShouldUpdateScroll' -import { withRoutes } from './fixtures' -import run from './run' - -describe('withSimpleScroll', () => { - HISTORIES.forEach(createHistory => { - describe(createHistory.name, () => { - let history, unlisten - - beforeEach(() => { - history = withRoutes(withSimpleScroll(createHistory())) - }) - - afterEach(done => { - if (unlisten) { - unlisten() - } - - delay(done) - }) - - it('should scroll to top on PUSH', done => { - unlisten = run(history, [ - () => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }, - () => { - expect(scrollTop(window)).toBe(0) - done() - } - ]) - }) - - it('should not scroll to top on POP', done => { - unlisten = run(history, [ - () => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }, - () => { - history.goBack() - }, - () => { - expect(scrollTop(window)).toNotBe(0) - done() - } - ]) - }) - - describeShouldUpdateScroll(withSimpleScroll, createHistory) - }) - }) -}) diff --git a/modules/__tests__/withStandardScroll-test.js b/modules/__tests__/withStandardScroll-test.js deleted file mode 100644 index 15fa692..0000000 --- a/modules/__tests__/withStandardScroll-test.js +++ /dev/null @@ -1,75 +0,0 @@ -import expect from 'expect' -import scrollTop from 'dom-helpers/query/scrollTop' - -import withStandardScroll from '../withStandardScroll' - -import { HISTORIES } from './config' -import delay from './delay' -import describeShouldUpdateScroll from './describeShouldUpdateScroll' -import { withRoutes } from './fixtures' -import run from './run' - -describe('withStandardScroll', () => { - HISTORIES.forEach(createHistory => { - describe(createHistory.name, () => { - let history, listenBeforeSpy, unlistenBefore, unlisten - - beforeEach(() => { - history = withRoutes(withStandardScroll(createHistory())) - - listenBeforeSpy = expect.createSpy() - unlistenBefore = history.listenBefore(listenBeforeSpy) - }) - - afterEach(done => { - if (unlisten) { - unlisten() - } - if (unlistenBefore) { - unlistenBefore() - } - - delay(done) - }) - - it('should scroll to top on PUSH', done => { - unlisten = run(history, [ - () => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }, - () => { - expect(listenBeforeSpy.calls.length).toBe(1) - expect(scrollTop(window)).toBe(0) - done() - } - ]) - }) - - it('should restore scroll on POP', done => { - unlisten = run(history, [ - () => { - // This will be ignored, but will exercise the throttle logic. - scrollTop(window, 10000) - - setTimeout(() => { - scrollTop(window, 15000) - delay(() => history.push('/detail')) - }) - }, - () => { - expect(listenBeforeSpy.calls.length).toBe(1) - history.goBack() - }, - () => { - expect(listenBeforeSpy.calls.length).toBe(2) - expect(scrollTop(window)).toBe(15000) - done() - } - ]) - }) - - describeShouldUpdateScroll(withStandardScroll, createHistory) - }) - }) -}) diff --git a/modules/index.js b/modules/index.js deleted file mode 100644 index 83a3f9a..0000000 --- a/modules/index.js +++ /dev/null @@ -1,6 +0,0 @@ -// This is just for convenience; users should import the specific behavior -// directly from `lib/`. - -export withScrollToTop from './withScrollToTop' -export withSimpleScroll from './withSimpleScroll' -export withStandardScroll from './withStandardScroll' diff --git a/modules/utils/setScrollRestoration.js b/modules/utils/setScrollRestoration.js deleted file mode 100644 index f853f36..0000000 --- a/modules/utils/setScrollRestoration.js +++ /dev/null @@ -1,13 +0,0 @@ -export default function setScrollRestoration(scrollRestoration) { - /* istanbul ignore if: not supported by any browsers on Travis */ - if ('scrollRestoration' in window.history) { - const oldScrollRestoration = window.history.scrollRestoration - window.history.scrollRestoration = scrollRestoration - - return function () { - window.history.scrollRestoration = oldScrollRestoration - } - } - - return null -} diff --git a/modules/utils/withScroll.js b/modules/utils/withScroll.js deleted file mode 100644 index 150761a..0000000 --- a/modules/utils/withScroll.js +++ /dev/null @@ -1,196 +0,0 @@ -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' - -// Try at most this many times to scroll, to avoid getting stuck. -const MAX_SCROLL_ATTEMPTS = 2 - -export default function withScroll( - history, - shouldUpdateScroll, - { getScrollPosition, start, stop, updateLocation } -) { - let checkScrollHandle - let scrollTarget - let numScrollAttempts - - function cancelCheckScroll() { - if (checkScrollHandle !== null) { - requestAnimationFrame.cancel(checkScrollHandle) - checkScrollHandle = null - } - } - - function onScroll() { - if (!scrollTarget) { - return - } - - const [ xTarget, yTarget ] = scrollTarget - const x = scrollLeft(window) - const y = scrollTop(window) - - if (x === xTarget && y === yTarget) { - scrollTarget = null - cancelCheckScroll() - } - } - - function checkScrollPosition() { - checkScrollHandle = 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 (!scrollTarget) { - return - } - - const [ x, y ] = scrollTarget - window.scrollTo(x, y) - - ++numScrollAttempts - - /* istanbul ignore if: paranoid guard */ - if (numScrollAttempts >= MAX_SCROLL_ATTEMPTS) { - scrollTarget = null - return - } - - checkScrollHandle = requestAnimationFrame(checkScrollPosition) - } - - // TODO: Actually track listener array. This count is not always correct. - let numListeners = 0 - - function checkStart() { - if (numListeners === 0) { - if (start) { - start(history) - } - - scrollTarget = null - numScrollAttempts = 0 - checkScrollHandle = null - - on(window, 'scroll', onScroll) - } - - ++numListeners - } - - function checkStop() { - --numListeners - - if (numListeners === 0) { - if (stop) { - stop() - } - - off(window, 'scroll', onScroll) - - cancelCheckScroll() - } - } - - function listenBefore(hook) { - checkStart() - const unlisten = history.listenBefore(hook) - - return function () { - unlisten() - checkStop() - } - } - - let currentLocation = null - let currentUpdateHandle = null - - function updateScrollPosition(scrollPosition) { - if (scrollPosition && !Array.isArray(scrollPosition)) { - scrollPosition = getScrollPosition(currentLocation) - } - - scrollTarget = scrollPosition - - // Check the scroll position to see if we even need to scroll. - onScroll() - if (!scrollTarget) { - return - } - - numScrollAttempts = 0 - checkScrollPosition() - } - - function onChange(location) { - const prevLocation = currentLocation - currentLocation = location - - listeners.forEach(listener => listener(location)) - - // Whatever we were doing before isn't relevant any more. - cancelCheckScroll() - - // withStandardScroll needs the new location even when not updating the - // scroll position, to update the current key. - if (updateLocation) { - updateLocation(location) - } - - // We don't need to update the handle here, because it will never be - // checked, since shouldUpdateScroll cannot change on us. - if (!shouldUpdateScroll) { - updateScrollPosition(true) - return - } - if (shouldUpdateScroll.length <= 2) { - updateScrollPosition(shouldUpdateScroll(prevLocation, location)) - return - } - - const updateHandle = {} - currentUpdateHandle = updateHandle - - shouldUpdateScroll(prevLocation, location, scrollPosition => { - if (updateHandle === currentUpdateHandle) { - currentUpdateHandle = null - updateScrollPosition(scrollPosition) - } - }) - } - - let listeners = [] - let unlisten - - function listen(listener) { - checkStart() - - if (listeners.length === 0) { - unlisten = history.listen(onChange) - } - - // Add the listener to the list afterward so we can manage calling it - // initially with the current location. - listeners.push(listener) - listener(currentLocation) - - return function () { - listeners = listeners.filter(item => item !== listener) - if (listeners.length === 0) { - unlisten() - } - - checkStop() - } - } - - return { - ...history, - listenBefore, - listen - } -} diff --git a/modules/withScrollToTop.js b/modules/withScrollToTop.js deleted file mode 100644 index 7061750..0000000 --- a/modules/withScrollToTop.js +++ /dev/null @@ -1,45 +0,0 @@ -import { POP } from 'history/lib/Actions' - -import setScrollRestoration from './utils/setScrollRestoration' -import withScroll from './utils/withScroll' - -/** - * `withScrollToTop` scrolls to the top of the page after any transition. - * - * This is not fully reliable for `POP` transitions. - */ -export default function withScrollToTop(history, shouldUpdateScroll) { - let unsetScrollRestoration - - function getScrollPosition({ action }) { - // If we didn't manage to disable the default scroll restoration, and it's - // a pop transition for which the browser might restore scroll position, - // then let the browser update to its remembered scroll position first, - // before we set the actual correct scroll position. - if (action === POP && !unsetScrollRestoration) { - setTimeout(() => window.scrollTo(0, 0)) - return null - } - - return [ 0, 0 ] - } - - function start() { - // This helps avoid some jankiness in fighting against the browser's - // default scroll behavior on `POP` transitions. - unsetScrollRestoration = setScrollRestoration('manual') - } - - function stop() { - /* istanbul ignore if: not supported by any browsers on Travis */ - if (unsetScrollRestoration) { - unsetScrollRestoration() - } - } - - return withScroll( - history, - shouldUpdateScroll, - { getScrollPosition, start, stop } - ) -} diff --git a/modules/withSimpleScroll.js b/modules/withSimpleScroll.js deleted file mode 100644 index a5ad5ce..0000000 --- a/modules/withSimpleScroll.js +++ /dev/null @@ -1,29 +0,0 @@ -import { POP } from 'history/lib/Actions' - -import withScroll from './utils/withScroll' - -/** - * `withSimpleScroll` scrolls to the top of the page on `PUSH` and `REPLACE` - * transitions, while allowing the browser to manage scroll position for `POP` - * transitions. - * - * This can give pretty good results with synchronous transitions on browsers - * like Chrome that don't update the scroll position until after they've - * notified `history` of the location change. It will not work as well when - * using asynchronous transitions or with browsers like Firefox that update - * the scroll position before emitting the location change. - */ -export default function withSimpleScroll(history, shouldUpdateScroll) { - // Don't override the browser's scroll behavior here - we actively want the - // the browser to take care of scrolling on `POP` transitions. - - function getScrollPosition({ action }) { - if (action === POP) { - return null - } - - return [ 0, 0 ] - } - - return withScroll(history, shouldUpdateScroll, { getScrollPosition }) -} diff --git a/modules/withStandardScroll.js b/modules/withStandardScroll.js deleted file mode 100644 index a4a9519..0000000 --- a/modules/withStandardScroll.js +++ /dev/null @@ -1,95 +0,0 @@ -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 { readState, saveState } from 'history/lib/DOMStateStorage' - -import setScrollRestoration from './utils/setScrollRestoration' -import withScroll from './utils/withScroll' - -/** - * `withStandardScroll` attempts to imitate native browser scroll behavior by - * recording updates to the window scroll position, then restoring the previous - * scroll position upon a `POP` transition. - */ -export default function withStandardScroll(history, shouldUpdateScroll) { - let currentKey - - function getScrollPosition() { - const state = readState(currentKey) - - if (!state || !state.scrollPosition) { - return [ 0, 0 ] - } - - return state.scrollPosition - } - - // `history` will invoke this listener synchronously, so `currentKey` will - // always be defined. - function updateLocation({ key }) { - currentKey = key - } - - let unsetScrollRestoration, unlistenScroll, unlistenBefore - - function start(history) { - // This helps avoid some jankiness in fighting against the browser's - // default scroll behavior on `POP` transitions. - unsetScrollRestoration = setScrollRestoration('manual') - - let savePositionHandle = null - - // We have to listen to each scroll update rather than to just location - // updates, because some browsers will update scroll position before - // emitting the location change. - function onScroll() { - if (savePositionHandle !== null) { - return - } - - // 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. - savePositionHandle = requestAnimationFrame(() => { - savePositionHandle = null - - const state = readState(currentKey) - const scrollPosition = [ scrollLeft(window), scrollTop(window) ] - - // 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(currentKey, { ...state, scrollPosition }) - }) - } - - on(window, 'scroll', onScroll) - unlistenScroll = () => off(window, 'scroll', onScroll) - - unlistenBefore = history.listenBefore(() => { - if (savePositionHandle !== null) { - requestAnimationFrame.cancel(savePositionHandle) - savePositionHandle = null - } - }) - } - - function stop() { - /* istanbul ignore if: not supported by any browsers on Travis */ - if (unsetScrollRestoration) { - unsetScrollRestoration() - } - - unlistenScroll() - unlistenBefore() - } - - return withScroll( - history, - shouldUpdateScroll, - { getScrollPosition, start, stop, updateLocation } - ) -} diff --git a/package.json b/package.json index ca0e0e6..ce3ebfa 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,20 @@ { "name": "scroll-behavior", "version": "0.5.0", - "description": "Scroll behaviors for use with history", + "description": "Scroll management for history", "files": [ "es", "lib" ], "main": "lib/index.js", - "jsnext:main": "es/index", + "jsnext:main": "es/index.js", "scripts": { "build": "npm run build-cjs && npm run build-es", - "build-cjs": "rimraf lib && babel ./modules -d lib --ignore '__tests__'", - "build-es": "rimraf es && babel ./modules -d es --blacklist=es6.modules --ignore '__tests__'", - "lint": "eslint modules *.js", + "build-cjs": "rimraf lib && cross-env BABEL_ENV=cjs babel ./src -d lib", + "build-es": "rimraf es && cross-env BABEL_ENV=es babel ./src -d es", + "lint": "eslint src test *.js", "prepublish": "npm run build", - "test": "npm run lint && karma start" + "test": "npm run lint && cross-env BABEL_ENV=cjs karma start" }, "repository": { "type": "git", @@ -31,34 +31,41 @@ "url": "https://github.com/taion/scroll-behavior/issues" }, "homepage": "https://github.com/taion/scroll-behavior#readme", - "devDependencies": { - "babel": "5.x", - "babel-core": "5.x", - "babel-eslint": "4.x", - "babel-loader": "5.x", - "chai": "^3.4.0", - "eslint": "^1.8.0", - "eslint-config-rackt": "^1.1.1", - "expect": "^1.12.2", - "history": "^2.0.0", - "isparta-loader": "1.x", - "karma": "^0.13.15", - "karma-chrome-launcher": "^0.2.1", - "karma-coverage": "^0.5.3", - "karma-coveralls": "^1.1.2", - "karma-firefox-launcher": "^0.1.6", - "karma-mocha": "^0.2.0", - "karma-mocha-reporter": "^1.1.1", - "karma-sourcemap-loader": "^0.3.6", - "karma-webpack": "^1.7.0", - "mocha": "^2.3.3", - "rimraf": "^2.4.3", - "webpack": "^1.12.13" + "dependencies": { + "dom-helpers": "^2.4.0" }, "peerDependencies": { "history": "^1.12.1 || ^2.0.0" }, - "dependencies": { - "dom-helpers": "^2.4.0" + "devDependencies": { + "babel-cli": "^6.7.7", + "babel-core": "^6.7.7", + "babel-eslint": "^6.0.4", + "babel-loader": "^6.2.4", + "babel-plugin-add-module-exports": "^0.1.4", + "babel-polyfill": "^6.7.4", + "babel-preset-es2015": "^6.6.0", + "babel-preset-es2015-loose": "^7.0.0", + "babel-preset-es2015-loose-native-modules": "^1.0.0", + "babel-preset-es2015-native-modules": "^6.6.0", + "babel-preset-stage-1": "^6.5.0", + "chai": "^3.5.0", + "cross-env": "^1.0.7", + "eslint": "^2.9.0", + "eslint-config-airbnb-base": "^2.0.0", + "eslint-plugin-import": "^1.6.1", + "history": "^2.1.1", + "karma": "^0.13.22", + "karma-chrome-launcher": "^1.0.1", + "karma-coverage": "^0.5.5", + "karma-coveralls": "^1.1.2", + "karma-firefox-launcher": "^0.1.7", + "karma-mocha": "^1.0.1", + "karma-mocha-reporter": "^2.0.2", + "karma-sourcemap-loader": "^0.3.7", + "karma-webpack": "^1.7.0", + "mocha": "^2.4.5", + "rimraf": "^2.5.2", + "webpack": "^1.13.0" } } diff --git a/src/ScrollBehavior.js b/src/ScrollBehavior.js new file mode 100644 index 0000000..8f8bc07 --- /dev/null +++ b/src/ScrollBehavior.js @@ -0,0 +1,150 @@ +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 { readState, saveState } from 'history/lib/DOMStateStorage'; + +// Try at most this many times to scroll, to avoid getting stuck. +const MAX_SCROLL_ATTEMPTS = 2; + +export default class ScrollBehavior { + constructor(history, getCurrentKey) { + this.getCurrentKey = getCurrentKey; + + /* istanbul ignore if: not supported by any browsers on Travis */ + if ('scrollRestoration' in window.history) { + // This helps avoid some jankiness in fighting against the browser's + // default scroll behavior on `POP` transitions. + this.oldScrollRestoration = window.history.scrollRestoration; + window.history.scrollRestoration = 'manual'; + } else { + this.oldScrollRestoration = null; + } + + this.savePositionHandle = null; + this.checkScrollHandle = null; + this.scrollTarget = null; + this.numScrollAttempts = 0; + + // We have to listen to each scroll update rather than to just location + // updates, because some browsers will update scroll position before + // emitting the location change. + on(window, 'scroll', this.onScroll); + + this.unlistenBefore = history.listenBefore(() => { + if (this.savePositionHandle !== null) { + requestAnimationFrame.cancel(this.savePositionHandle); + this.savePositionHandle = null; + } + }); + } + + stop() { + /* istanbul ignore if: not supported by any browsers on Travis */ + if (this.oldScrollRestoration) { + window.history.scrollRestoration = this.oldScrollRestoration; + } + + off(window, 'scroll', this.onScroll); + this.cancelCheckScroll(); + + this.unlistenBefore(); + } + + updateScroll(scrollPosition) { + // Whatever we were doing before isn't relevant any more. + this.cancelCheckScroll(); + + if (scrollPosition && !Array.isArray(scrollPosition)) { + this.scrollTarget = this.getScrollPosition(); + } else { + this.scrollTarget = scrollPosition; + } + + // Check the scroll position to see if we even need to scroll. + this.onScroll(); + + if (!this.scrollTarget) { + return; + } + + this.numScrollAttempts = 0; + this.checkScrollPosition(); + } + + onScroll = () => { + // 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.savePositionHandle === null) { + this.savePositionHandle = requestAnimationFrame(this.savePosition); + } + + if (this.scrollTarget) { + const [xTarget, yTarget] = this.scrollTarget; + const x = scrollLeft(window); + const y = scrollTop(window); + + if (x === xTarget && y === yTarget) { + this.scrollTarget = null; + this.cancelCheckScroll(); + } + } + }; + + savePosition = () => { + this.savePositionHandle = null; + + const currentKey = this.getCurrentKey(); + const scrollPosition = [scrollLeft(window), scrollTop(window)]; + + // 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. + const state = readState(currentKey); + saveState(currentKey, { ...state, scrollPosition }); + }; + + cancelCheckScroll() { + if (this.checkScrollHandle !== null) { + requestAnimationFrame.cancel(this.checkScrollHandle); + this.checkScrollHandle = null; + } + } + + getScrollPosition() { + const state = readState(this.getCurrentKey()); + if (!state || !state.scrollPosition) { + return [0, 0]; + } + + return state.scrollPosition; + } + + checkScrollPosition = () => { + this.checkScrollHandle = 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.scrollTarget) { + return; + } + + const [x, y] = this.scrollTarget; + window.scrollTo(x, y); + + ++this.numScrollAttempts; + + /* istanbul ignore if: paranoid guard */ + if (this.numScrollAttempts >= MAX_SCROLL_ATTEMPTS) { + this.scrollTarget = null; + return; + } + + this.checkScrollHandle = requestAnimationFrame(this.checkScrollPosition); + }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..40e7eb1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,56 @@ +import ScrollBehavior from './ScrollBehavior'; + +export default function withScroll(history, shouldUpdateScroll) { + // history will invoke the onChange callback synchronously, so + // currentLocation will always be defined when needed. + let currentLocation = null; + + function getCurrentKey() { + return currentLocation.key; + } + + let listeners = []; + let scrollBehavior = null; + + function onChange(location) { + const prevLocation = currentLocation; + currentLocation = location; + + listeners.forEach(listener => listener(location)); + + let scrollPosition; + if (!shouldUpdateScroll) { + scrollPosition = true; + } else { + scrollPosition = shouldUpdateScroll(prevLocation, location); + } + + scrollBehavior.updateScroll(scrollPosition); + } + + let unlisten = null; + + function listen(listener) { + if (listeners.length === 0) { + scrollBehavior = new ScrollBehavior(history, getCurrentKey); + unlisten = history.listen(onChange); + } + + listeners.push(listener); + listener(currentLocation); + + return () => { + listeners = listeners.filter(item => item !== listener); + + if (listeners.length === 0) { + scrollBehavior.stop(); + unlisten(); + } + }; + } + + return { + ...history, + listen, + }; +} diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..1a13a3e --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,8 @@ +{ + "env": { + "mocha": true + }, + "globals": { + "expect": false + } +} diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 0000000..907e64a --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,32 @@ +export function withRoutes(history) { + let container; + + // This will only be called once, so no need to guard. + function listen(listener) { + container = document.createElement('div'); + document.body.appendChild(container); + + const unlisten = history.listen(location => { + listener(location); + + if (location.pathname === '/') { + container.style.height = '20000px'; + container.style.width = '20000px'; + } else { + container.style.height = '10000px'; + container.style.width = '10000px'; + } + }); + + return () => { + unlisten(); + document.body.removeChild(container); + container = null; + }; + } + + return { + ...history, + listen, + }; +} diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..554b230 --- /dev/null +++ b/test/index.js @@ -0,0 +1,4 @@ +import 'babel-polyfill'; + +const testsContext = require.context('.', true, /\.spec\.js$/); +testsContext.keys().forEach(testsContext); diff --git a/test/run.js b/test/run.js new file mode 100644 index 0000000..9d8cb20 --- /dev/null +++ b/test/run.js @@ -0,0 +1,34 @@ +export function delay(cb) { + // Give throttled scroll listeners time to settle down. + requestAnimationFrame(() => setTimeout(cb)); +} + +export default function run(history, steps) { + window.history.replaceState(null, null, '/'); + + let i = 0; + let running = false; + + return history.listen(() => { + if (i === steps.length) { + return; + } + + // Don't spuriously fire steps while things are settling down before the + // first step. + if (i === 0) { + if (running) { + return; + } + + running = true; + } + + // First wait a extra tick for all the scroll callbacks to fire before + // position, even if we don't need an extra delay. + delay(() => { + // Don't increment i until we run the step, for the above check. + steps[i++](); + }); + }); +} diff --git a/test/withScroll.spec.js b/test/withScroll.spec.js new file mode 100644 index 0000000..65319d3 --- /dev/null +++ b/test/withScroll.spec.js @@ -0,0 +1,120 @@ +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 { withRoutes } from './fixtures'; +import run, { delay } from './run'; + +describe('withStandardScroll', () => { + [ + createBrowserHistory, + createHashHistory, + ].forEach(createHistory => { + describe(createHistory.name, () => { + let unlisten; + + afterEach(done => { + if (unlisten) { + unlisten(); + } + + delay(done); + }); + + describe('default behavior', () => { + let history; + + beforeEach(() => { + history = withRoutes(withScroll(createHistory())); + }); + + it('should scroll to top on PUSH', done => { + unlisten = run(history, [ + () => { + scrollTop(window, 15000); + delay(() => history.push('/detail')); + }, + () => { + expect(scrollTop(window)).to.equal(0); + done(); + }, + ]); + }); + + it('should restore scroll on POP', done => { + unlisten = run(history, [ + () => { + // This will be ignored, but will exercise the throttle logic. + scrollTop(window, 10000); + + setTimeout(() => { + scrollTop(window, 15000); + delay(() => history.push('/detail')); + }); + }, + () => { + history.goBack(); + }, + () => { + expect(scrollTop(window)).to.equal(15000); + done(); + }, + ]); + }); + }); + + describe('custom behavior', () => { + it('should allow scroll suppression', done => { + const history = withRoutes(withScroll( + createHistory(), + (prevLocation, location) => ( + !prevLocation || prevLocation.pathname !== location.pathname + ) + )); + + unlisten = run(history, [ + () => { + history.push('/oldpath'); + }, + () => { + scrollTop(window, 5000); + delay(() => history.push('/oldpath?key=value')); + }, + () => { + expect(scrollTop(window)).to.equal(5000); + history.push('/newpath'); + }, + () => { + expect(scrollTop(window)).to.equal(0); + done(); + }, + ]); + }); + + it('should allow custom position', done => { + const history = withRoutes(withScroll( + createHistory(), () => [10, 20] + )); + + unlisten = run(history, [ + () => { + history.push('/oldpath'); + }, + () => { + history.push('/newpath'); + }, + () => { + expect(scrollLeft(window)).to.equal(10); + expect(scrollTop(window)).to.equal(20); + done(); + }, + ]); + }); + }); + }); + }); +}); diff --git a/tests.webpack.js b/tests.webpack.js deleted file mode 100644 index 225af26..0000000 --- a/tests.webpack.js +++ /dev/null @@ -1,2 +0,0 @@ -const context = require.context('./modules', true, /-test\.js$/) -context.keys().forEach(context)