├── .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 |
--------------------------------------------------------------------------------