├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── SessionStorage.js └── index.js ├── wallaby.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | parserOptions: { 4 | ecmaFeatures: { 5 | generators: true, 6 | experimentalObjectRestSpread: true 7 | }, 8 | sourceType: 'module', 9 | allowImportExportEverywhere: false 10 | }, 11 | extends: ['airbnb'], 12 | globals: { 13 | window: true, 14 | document: true, 15 | __dirname: true, 16 | __DEV__: true, 17 | CONFIG: true, 18 | process: true, 19 | jest: true, 20 | describe: true, 21 | test: true, 22 | it: true, 23 | expect: true, 24 | beforeEach: true, 25 | sessionStorage: true 26 | }, 27 | 'import/resolver': { 28 | node: { 29 | extensions: ['.js', '.css', '.json', '.styl'] 30 | } 31 | }, 32 | 'import/extensions': ['.js'], 33 | 'import/ignore': ['node_modules', '\\.(css|styl|svg|json)$'], 34 | rules: { 35 | 'no-shadow': 0, 36 | 'no-use-before-define': 0, 37 | 'no-param-reassign': 0, 38 | 'react/prop-types': 0, 39 | 'react/no-render-return-value': 0, 40 | 'no-confusing-arrow': 0, 41 | 'no-underscore-dangle': 0, 42 | 'no-plusplus': 0, 43 | camelcase: 1, 44 | 'prefer-template': 1, 45 | 'react/no-array-index-key': 1, 46 | 'global-require': 1, 47 | 'react/jsx-indent': 1, 48 | 'dot-notation': 1, 49 | 'import/no-named-default': 1, 50 | 'no-unused-vars': 1, 51 | 'import/no-unresolved': 1, 52 | 'class-methods-use-this': 1, 53 | 'consistent-return': 1, 54 | semi: [2, 'never'], 55 | 'no-console': [2, { allow: ['warn', 'error'] }], 56 | 'jsx-quotes': [2, 'prefer-single'], 57 | 'react/jsx-filename-extension': [2, { extensions: ['.jsx', '.js'] }], 58 | 'spaced-comment': [2, 'always', { markers: ['?'] }], 59 | 'arrow-parens': [2, 'as-needed', { requireForBlockBody: false }], 60 | 'brace-style': [2, 'stroustrup'], 61 | 'import/no-extraneous-dependencies': [ 62 | 'error', 63 | { 64 | devDependencies: true, 65 | optionalDependencies: true, 66 | peerDependencies: true 67 | } 68 | ], 69 | 'comma-dangle': [ 70 | 2, 71 | { 72 | arrays: 'never', 73 | objects: 'never', 74 | imports: 'never', 75 | exports: 'never', 76 | functions: 'never' 77 | } 78 | ], 79 | 'max-len': [ 80 | 'error', 81 | { 82 | code: 80, 83 | tabWidth: 2, 84 | ignoreUrls: true, 85 | ignoreComments: true, 86 | ignoreRegExpLiterals: true, 87 | ignoreStrings: true, 88 | ignoreTemplateLiterals: true 89 | } 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | *.log 5 | .idea 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | __tests__ 3 | src 4 | .babelrc 5 | .codeclimate.yml 6 | .editorconfig 7 | .eslintrc.js 8 | .snyk 9 | .travis.yml 10 | wallaby.js 11 | webpack.config.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | cache: yarn 5 | script: 6 | - node_modules/.bin/travis-github-status lint 7 | notifications: 8 | email: false 9 | after_success: 10 | - npm run semantic-release 11 | branches: 12 | except: 13 | - /^v\d+\.\d+\.\d+$/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 James Gillmore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-first-router-restore-scroll 2 | 3 | This package provides complete scroll restoration for [redux-first-router](https://github.com/faceyspacey/redux-first-router) through the call of a single function. It also insures hash changes work as you would expect (e.g. like when you click `#links` to different section of a Github readme it automatically scrolls, and allows you to use the browser back/next buttons to move between sections you've visited). 4 | 5 | Example: 6 | 7 | ```js 8 | import restoreScroll from 'redux-first-router-restore-scroll' 9 | connectRoutes(history, routesMap, { restoreScroll: restoreScroll() }) 10 | ``` 11 | 12 | 13 | ## Advanced Usage 14 | To disable automatic scroll restoration, pass `manual: true`: 15 | 16 | ```js 17 | import restoreScroll from 'redux-first-router-restore-scroll' 18 | 19 | connectRoutes(history, routesMap, { 20 | restoreScroll: restoreScroll({ manual: true }) 21 | }) 22 | ``` 23 | 24 | See [Manual Scroll Position Updates](#manual-scroll-position-updates) below for how to handle scroll restoration manually. 25 | 26 | If you'd like to implement custom scroll positioning, provide a `shouldUpdateScroll` handler as seen below: 27 | 28 | ```js 29 | import restoreScroll from 'redux-first-router-restore-scroll' 30 | 31 | connectRoutes(history, routesMap, { 32 | restoreScroll: restoreScroll({ 33 | shouldUpdateScroll: (prev, locationState) => { 34 | // disable scroll restoration on history state changes 35 | // note: this is useful if you want to maintain scroll position from previous route 36 | if (prev.type === 'HOME' && locationState.type === 'CATEGORY') { 37 | return false 38 | } 39 | 40 | // scroll into view HTML element with this ID or name attribute value 41 | else if (locationState.load && locationState.type === 'USER') { 42 | return 'profile-box' 43 | } 44 | 45 | 46 | // return an array of xy coordinates to scroll there 47 | else if (locationState.payload.coords) { 48 | return [coords.x, coords.y] 49 | } 50 | 51 | // Accurately emulate the default behavior of scrolling to the top on new history 52 | // entries, and to previous positions on pop state + hash changes. 53 | // This is the default behavior, and this callback is not needed if this is all you want. 54 | return true 55 | } 56 | }) 57 | }) 58 | ``` 59 | 60 | 61 | 62 | ## Manual Scroll Position Updates 63 | It's one of the core premises of `redux-first-router` that you avoid using 3rd party container components that update unnecessarily behind the scenes (such as the `route` component from *React Router*), and that Redux's `connect` + React's `shouldComponentUpdate` stay your primary mechanism/container for controlling updates. It's all too common for a lot more updates to be going on than you're aware. The browser isn't perfect and jank is a fact of life for large animation-heavy applications. By keeping your updating containers to userland Redux containers (as much as possible), you keep your app's rendering performance in your control. 64 | 65 | **Everything `redux-first-router` is doing is to make Redux remain as your go-to for optimizing rendering performance.** 66 | 67 | It's for this reason we avoid a top level `` provider component which listens and updates in response to every single `location` state change. It may just be the virtual DOM which re-renders, but cycles add up. 68 | 69 | Therefore, in some cases you may want to update the scroll position manually. So rather than provide a `` container component, we expose an API so you can update scroll position in places you likely already are listening to such updates: 70 | 71 | ```js 72 | import React from 'react' 73 | import { updateScroll } from 'redux-first-router' // note: this is the main package 74 | 75 | class MyComponent extends React.Component { 76 | componentDidUpdate() { 77 | const dispatch = this.props.dispatch 78 | requestData() 79 | .then(payload => dispatch({ type: 'NEW_DATA', payload }) 80 | .then(() = updateScroll()) 81 | } 82 | 83 | render() {...} 84 | } 85 | ``` 86 | > The purpose of calling `updateScroll` after the new data is here and rendered is so that the page can be scrolled down to a portion of the page that might not have existed yet (e.g. because a spinner was showing instead). 87 | 88 | Note however that if you are using `redux-first-router`'s `thunk` or `chunks` options for your routes, `updateScroll` will automatically be called for you after the corresponding promises resolve. So you may never need this. 89 | 90 | 91 | ## Custom Storage Backend 92 | 93 | To implement a custom backend storage for scroll state, pass a custom `stateStorage` object. The object should implement the methods as described by [scroll-behavior](https://github.com/taion/scroll-behavior) as well as a function called `setPrevKey` that keeps track of the previous key. See the default [sessionStorage backed example](https://github.com/faceyspacey/redux-first-router-restore-scroll/blob/master/src/SessionStorage.js). 94 | 95 | ```js 96 | import restoreScroll from 'redux-first-router-restore-scroll' 97 | import someStorageMechanism from './someStorageMechanism' 98 | 99 | function determineKeyFromLocation(location, key) { 100 | // figure out a key for your storage from location and nullable key, not a robust example 101 | return `${location.key || location.hash || 'loadPage'}${key}` 102 | } 103 | 104 | let prevKey; 105 | const stateStorage = { 106 | setPrevKey(key) { 107 | prevKey = key; 108 | }, 109 | read(location, key) { 110 | // somewhere you have stored state 111 | return someStorageMechanism.get(determineKeyFromLocation(location, key)) 112 | }, 113 | save(location, key, value) { 114 | // somewhere you will store state 115 | someStorageMechanism.set(determineKeyFromLocation(location, key), value) 116 | } 117 | } 118 | 119 | connectRoutes(history, routesMap, { 120 | restoreScroll: restoreScroll({ 121 | stateStorage 122 | }) 123 | }) 124 | ``` 125 | 126 | ## Caveats 127 | In React 16 ("Fiber"), there is more asynchrony involved, and therefore you may need to pass the `manual` option and create a component at the top of your component tree like the following: 128 | 129 | ```js 130 | import React from 'react' 131 | import { connect } from 'react-redux' 132 | import { updateScroll } from 'redux-first-router' 133 | 134 | class ScrollContext extends React.Component { 135 | componentDidUpdate(prevProps) { 136 | if (prevProps.path !== this.props.path) { 137 | updateScroll() 138 | } 139 | } 140 | 141 | render() { 142 | return this.props.children 143 | } 144 | } 145 | export default connect(({ location }) => ({ path: location.pathname }))(ScrollContext) 146 | ``` 147 | > Now just wrap your top level `` component inside ``. Its `componentDidUpdate` method will be called last and the remainder of your page (i.e. child components) will have already rendered. As a result, the window will be able to properly scroll down to a portion of the page that now exists. 148 | 149 | Again, since `redux-first-router` is based on Redux, our goal is to avoid a huge set of library components, but rather to facilitate your frictionless implementation of tried and true Redux connected container patterns. We will however try to find a way to automate this for you in the main `redux-first-router` package on history transitions if Fiber provides some sort of handler like: `React.runAfterUpdates(updateScroll)`, similar to React Native's `InteractionManager.runAfterInteractions`. 150 | 151 | 152 | ## Notes 153 | Modern browsers like Chrome attempt to provide the default behavior, but we have found 154 | it to be flakey in fact. It's pretty good in Chrome, but doesn't always happen. If all you want is the default behavior and nothing more, 155 | simply call `restoreScroll()` and assign it to the `restoreScroll` option of `redux-first-router`'s option map. That results in the same as 156 | returning `true` above. 157 | 158 | 159 | ## Scroll Restoration for Elements other than `window` 160 | We got you covered. Please checkout [redux-first-router-scroll-container](https://github.com/faceyspacey/redux-first-router-scroll-container). 161 | 162 | 163 | ## Scroll Restoration for React Native 164 | We got you covered! Please checkout [redux-first-router-scroll-container-native](https://github.com/faceyspacey/redux-first-router-scroll-container-native). 165 | 166 | 167 | ## Thanks 168 | Our Scroll Restoration package comes thanks to: https://github.com/taion/scroll-behavior, which powered [react-router-scroll](https://github.com/taion/react-router-scroll) in older versions of React Router. See either for more information on how this works. 169 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-first-router-restore-scroll", 3 | "version": "1.2.2", 4 | "description": "think of your app in states not routes (and, yes, while keeping the address bar in sync)", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "babel src -d dist", 8 | "build:umd": "BABEL_ENV=commonjs NODE_ENV=development webpack src/index.js dist/redux-first-router-restore-scroll.js", 9 | "build:umd:min": "BABEL_ENV=commonjs NODE_ENV=production webpack src/index.js dist/redux-first-router-restore-scroll.min.js", 10 | "test": "jest", 11 | "lint": "eslint --fix ./", 12 | "format": "prettier --single-quote --semi=false --write '{src,__tests__,__test-helpers__}/**/*.js' && npm run lint", 13 | "precommit": "lint-staged --verbose && npm test", 14 | "cm": "git-cz", 15 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 16 | "prepublish": "npm run build && npm run build:umd && npm run build:umd:min" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/faceyspacey/redux-first-router-restore-scroll.git" 21 | }, 22 | "author": "James Gillmore ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/faceyspacey/redux-first-router-restore-scroll/issues" 26 | }, 27 | "homepage": "https://github.com/faceyspacey/redux-first-router-restore-scroll#readme", 28 | "dependencies": { 29 | "scroll-behavior": "^0.9.3" 30 | }, 31 | "devDependencies": { 32 | "babel-cli": "^6.23.0", 33 | "babel-core": "^6.24.0", 34 | "babel-eslint": "^7.2.3", 35 | "babel-loader": "^7.0.0", 36 | "babel-preset-es2015": "^6.24.1", 37 | "babel-preset-react": "^6.24.1", 38 | "babel-preset-stage-0": "^6.24.1", 39 | "commitizen": "^2.9.6", 40 | "cz-conventional-changelog": "^2.0.0", 41 | "eslint": "^3.19.0", 42 | "eslint-config-airbnb": "^14.1.0", 43 | "eslint-plugin-import": "^2.2.0", 44 | "eslint-plugin-jsx-a11y": "^4.0.0", 45 | "eslint-plugin-react": "^6.10.3", 46 | "history": "^4.5.1", 47 | "husky": "^0.13.2", 48 | "jest": "^19.0.2", 49 | "lint-staged": "^3.4.0", 50 | "prettier": "^1.2.2", 51 | "semantic-release": "^6.3.2", 52 | "travis-github-status": "^1.6.3", 53 | "webpack": "2.4.1" 54 | }, 55 | "peerDependencies": { 56 | "redux-first-router": "*" 57 | }, 58 | "config": { 59 | "commitizen": { 60 | "path": "./node_modules/cz-conventional-changelog" 61 | } 62 | }, 63 | "lint-staged": { 64 | "*.js": [ 65 | "prettier --single-quote --semi=false --write", 66 | "eslint --fix", 67 | "git add" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/SessionStorage.js: -------------------------------------------------------------------------------- 1 | const STATE_KEY_PREFIX = '@@scroll|' 2 | 3 | let prevKey = null 4 | 5 | export default class SessionStorage { 6 | setPrevKey(key) { 7 | prevKey = key 8 | } 9 | 10 | read(location, key) { 11 | const stateKey = this.getStateKey(location, key) 12 | try { 13 | const value = sessionStorage.getItem(stateKey) 14 | return JSON.parse(value) 15 | } 16 | catch (e) { 17 | console.warn(e) 18 | } 19 | } 20 | 21 | save(location, key, value) { 22 | if (key) { 23 | location = { key: prevKey, hash: location.hash } 24 | } 25 | 26 | const stateKey = this.getStateKey(location, key) 27 | const storedValue = JSON.stringify(value) 28 | 29 | try { 30 | sessionStorage.setItem(stateKey, storedValue) 31 | } 32 | catch (e) { 33 | console.warn(e) 34 | } 35 | 36 | if (key) { 37 | const newKey = location.key || location.hash || 'loadPage' 38 | if (newKey !== prevKey) { 39 | prevKey = newKey 40 | } 41 | } 42 | } 43 | 44 | getStateKey(location, key) { 45 | const locationKey = location.key || location.hash || 'loadPage' 46 | const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}` 47 | return key == null ? stateKeyBase : `${stateKeyBase}|${key}` 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ScrollBehavior from 'scroll-behavior' 2 | import SessionStorage from './SessionStorage' 3 | 4 | export default ( 5 | { shouldUpdateScroll, manual, stateStorage } = {} 6 | ) => history => { 7 | if (typeof window === 'undefined') return 8 | 9 | const behaviorStateStorage = stateStorage || new SessionStorage() 10 | const behavior = new ScrollBehavior({ 11 | addTransitionHook: history.listen, 12 | stateStorage: behaviorStateStorage, 13 | getCurrentLocation: () => ({ 14 | ...history.location, 15 | action: history.action 16 | }), 17 | shouldUpdateScroll 18 | }) 19 | 20 | behavior.setPrevKey = () => { 21 | const key = history.location.key || history.location.hash || 'loadPage' 22 | behaviorStateStorage.setPrevKey(key) 23 | } 24 | 25 | behavior.manual = manual 26 | 27 | return behavior 28 | } 29 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = wallaby => { 2 | process.env.NODE_ENV = 'test' 3 | 4 | return { 5 | files: [ 6 | { pattern: 'src/**/*.js', load: false }, 7 | { pattern: 'package.json', load: false }, 8 | { pattern: '__tests__/**/*.snap', load: false } 9 | ], 10 | 11 | tests: ['__tests__/**/*.js'], 12 | 13 | env: { 14 | type: 'node', 15 | runner: 'node' 16 | }, 17 | 18 | testFramework: 'jest', 19 | compilers: { 20 | '**/*.js': wallaby.compilers.babel({ babelrc: true }) 21 | }, 22 | debug: false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | const env = process.env.NODE_ENV 4 | 5 | const config = { 6 | module: { 7 | loaders: [ 8 | { test: /\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ } 9 | ] 10 | }, 11 | output: { 12 | library: 'ReduxFirstRouterRestoreScroll', 13 | libraryTarget: 'umd' 14 | }, 15 | plugins: [ 16 | new webpack.optimize.OccurrenceOrderPlugin(), 17 | new webpack.DefinePlugin({ 18 | 'process.env.NODE_ENV': JSON.stringify(env) 19 | }) 20 | ] 21 | } 22 | 23 | if (env === 'production') { 24 | config.plugins.push( 25 | new webpack.optimize.UglifyJsPlugin({ 26 | compressor: { 27 | pure_getters: true, 28 | unsafe: true, 29 | unsafe_comps: true, 30 | warnings: false, 31 | screw_ie8: false 32 | }, 33 | mangle: { 34 | screw_ie8: false 35 | }, 36 | output: { 37 | screw_ie8: false 38 | } 39 | }) 40 | ) 41 | } 42 | 43 | module.exports = config 44 | --------------------------------------------------------------------------------