Skip to content

Commit e326fd2

Browse files
feat: scroll restoration (#65)
This is modelled after https://github.com/faceyspacey/redux-first-router-restore-scroll - Add key in options `restoreScroll`, set to this new package by default, which is a plugin to handle restoring scroll positions after route transitions - When a plugin is in use, `api.scrollRestorer` is available for programmatic access (e.g. in react components which are scroll containers and which save their scroll position) - The behaviour can be customized in the `shouldUpdateScroll` option when creating the scroll plugin. Through `api.scrollRestorer.getSavedScrollPosition` it is also possible to set the initial scroll position to a previous entry in the history stack. BREAKING CHANGE: scroll restoration is enabled by default Fixes #62
1 parent 406b95f commit e326fd2

File tree

13 files changed

+277
-4
lines changed

13 files changed

+277
-4
lines changed

config/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
}
2929
},
3030
"references": [
31+
{ "path": "../packages/scroll-restorer" },
3132
{ "path": "../packages/types" },
3233
{ "path": "../packages/utils" },
3334
{ "path": "../packages/utils/tests" }

packages/boilerplate/src/css/Switcher.css

-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
position: relative;
33
margin: 0 10px;
44
height: calc(100vh - 20px);
5-
overflow: hidden;
65
-webkit-box-flex: 1;
76
-ms-flex: 1;
87
flex: 1;

packages/rudy/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"homepage": "https://github.com/respond-framework/rudy",
3838
"dependencies": {
3939
"@respond-framework/middleware-change-page-title": "^1.0.1-test.3",
40+
"@respond-framework/scroll-restorer": "^0.1.0-test.1",
4041
"@respond-framework/utils": "^0.1.1-test.3",
4142
"path-to-regexp": "^2.1.0",
4243
"prop-types": "^15.6.0",

packages/rudy/src/core/createRouter.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @flow
22
import qs from 'qs'
3-
import { createSelector } from '@respond-framework/utils'
3+
import { createSelector, isServer } from '@respond-framework/utils'
4+
import defaultCreateRestoreScroll from '@respond-framework/scroll-restorer'
45
import type {
56
Options,
67
Store,
@@ -31,6 +32,8 @@ import {
3132
call,
3233
enter,
3334
changePageTitle,
35+
saveScroll,
36+
restoreScroll,
3437
} from '../middleware'
3538

3639
export default (
@@ -45,11 +48,13 @@ export default (
4548
// Server: don't allow client-centric callbacks (onEnter, onLeave, beforeLeave)
4649
call('beforeLeave', { prev: true }),
4750
call('beforeEnter', { runOnServer: true }),
51+
saveScroll,
4852
enter,
4953
changePageTitle({ title: options.title }),
5054
call('onLeave', { prev: true }),
5155
call('onEnter', { runOnHydrate: true }),
5256
call('thunk', { cache: true, runOnServer: true }),
57+
restoreScroll,
5358
call('onComplete', { runOnServer: true }),
5459
],
5560
) => {
@@ -61,6 +66,7 @@ export default (
6166
createReducer: createLocationReducer = createReducer,
6267
createInitialState: createState = createInitialState,
6368
onError: onErr,
69+
restoreScroll: scrollRestorerCreator = defaultCreateRestoreScroll(),
6470
} = options
6571

6672
// assign to options so middleware can override them in 1st pass if necessary
@@ -85,6 +91,10 @@ export default (
8591
const has = (name: string) => wares[name]
8692
const ctx = { busy: false }
8793
const api = { routes, history, options, register, has, ctx }
94+
95+
const scrollRestorer = isServer() ? undefined : scrollRestorerCreator(api)
96+
api.scrollRestorer = scrollRestorer
97+
8898
const onError = call('onError', { runOnServer: true, runOnHydrate: true })(
8999
api,
90100
)

packages/rudy/src/middleware/index.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export { default as transformAction } from './transformAction'
22
export { default as enter } from './enter'
33
export { default as call, shouldCall } from './call'
4+
export { default as saveScroll } from './saveScroll'
5+
export { default as restoreScroll } from './restoreScroll'
46

57
export { default as pathlessRoute } from './pathlessRoute'
68
export { default as anonymousThunk } from './anonymousThunk'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default (api) => {
2+
const { scrollRestorer } = api
3+
if (scrollRestorer) {
4+
return scrollRestorer.restoreScroll(api)
5+
}
6+
return (_, next) => next()
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default (api) => {
2+
const { scrollRestorer } = api
3+
if (scrollRestorer) {
4+
return scrollRestorer.saveScroll(api)
5+
}
6+
return (_, next) => next()
7+
}

packages/scroll-restorer/package.json

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@respond-framework/scroll-restorer",
3+
"version": "0.1.0-test.1",
4+
"description": "Rudy middleware to restore scroll position after navigation",
5+
"main": "cjs/index.js",
6+
"module": "es/index.js",
7+
"rudy-src-main": "src/index.ts",
8+
"types": "ts/index.d.ts",
9+
"repository": "https://github.com/respond-framework/rudy/tree/master/packages/scroll-restorer",
10+
"contributors": [
11+
"Daniel Playfair Cal <[email protected]>"
12+
],
13+
"license": "MIT",
14+
"private": false,
15+
"publishConfig": {
16+
"access": "public"
17+
},
18+
"files": [
19+
"cjs",
20+
"es",
21+
"ts"
22+
],
23+
"scripts": {
24+
"prepare": "yarn run build",
25+
"build:cjs": "babel --root-mode upward --source-maps true -x .tsx,.ts,.js,.jsx src -d cjs",
26+
"build:es": "babel --root-mode upward --source-maps true -x .tsx,.ts,.js,.jsx --env-name es src -d es",
27+
"build:ts": "tsc -b",
28+
"build": "yarn run build:cjs && yarn run build:es && yarn run build:ts",
29+
"clean": "rimraf cjs es ts *.tsbuildinfo",
30+
"prettier": "prettier",
31+
"is-pretty": "prettier --ignore-path=../../config/.prettierignore '**/*' --list-different",
32+
"prettify": "prettier --ignore-path=../../config/.prettierignore '**/*' --write"
33+
},
34+
"dependencies": {
35+
"@respond-framework/utils": "^0.1.1-test.1",
36+
"scroll-behavior": "git+https://github.com/hedgepigdaniel/scroll-behavior.git#build/intermediate-scroll"
37+
},
38+
"peerDependencies": {
39+
"@respond-framework/types": "^0.1.1-test.1"
40+
},
41+
"devDependencies": {
42+
"@respond-framework/types": "^0.1.1-test.1"
43+
}
44+
}

packages/scroll-restorer/src/index.ts

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* eslint-env browser */
2+
import ScrollBehavior, {
3+
TransitionHook,
4+
ScrollPosition,
5+
ScrollTarget,
6+
} from 'scroll-behavior'
7+
import {
8+
Api,
9+
Middleware,
10+
LocationEntry,
11+
FluxStandardRoutingAction,
12+
Request,
13+
ScrollRestorer,
14+
ScrollRestorerCreator,
15+
} from '@respond-framework/types'
16+
17+
export { ScrollPosition } from 'scroll-behavior'
18+
19+
export type ShouldUpdateScroll<Action extends FluxStandardRoutingAction> = (
20+
request: Request<Action>,
21+
) => ScrollTarget
22+
23+
export type RestoreScrollOptions<Action extends FluxStandardRoutingAction> = {
24+
shouldUpdateScroll?: ShouldUpdateScroll<Action>
25+
}
26+
27+
type Location<Action extends FluxStandardRoutingAction> = LocationEntry<
28+
Action
29+
> & {
30+
action: 'unknown'
31+
}
32+
33+
export class RudyScrollRestorer<Action extends FluxStandardRoutingAction>
34+
implements ScrollRestorer<Action> {
35+
private options: RestoreScrollOptions<Action>
36+
37+
private behavior: ScrollBehavior<Location<Action>, Request<Action>>
38+
39+
private lastRequest?: Request<Action>
40+
41+
private api: Api<Action>
42+
43+
private transitionHooks: { [index: string]: TransitionHook } = {}
44+
45+
private nextHookIndex = 0
46+
47+
private makeStorageKey = (
48+
entry: LocationEntry<Action> | null,
49+
scrollBehaviorKey: string | null,
50+
): string =>
51+
`@@rudy-restore-scroll/${
52+
entry ? `${entry.location.key}/` : ``
53+
}${JSON.stringify(scrollBehaviorKey)}`
54+
55+
private saveScrollPosition = (
56+
entry: LocationEntry<Action>,
57+
key: string | null,
58+
value: ScrollPosition,
59+
): void => {
60+
window.sessionStorage.setItem(
61+
this.makeStorageKey(entry, key),
62+
JSON.stringify(value),
63+
)
64+
}
65+
66+
readScrollPosition = (
67+
entry: LocationEntry<Action>,
68+
key: string | null,
69+
): ScrollPosition | null => {
70+
const savedItem = window.sessionStorage.getItem(
71+
this.makeStorageKey(entry, key),
72+
)
73+
if (savedItem === null) {
74+
return null
75+
}
76+
try {
77+
return JSON.parse(savedItem)
78+
} catch {
79+
return null
80+
}
81+
}
82+
83+
constructor(api: Api<Action>, options: RestoreScrollOptions<Action> = {}) {
84+
this.api = api
85+
this.options = options
86+
this.behavior = new ScrollBehavior<Location<Action>, Request<Action>>({
87+
addTransitionHook: (hook: TransitionHook) => {
88+
const hookIndex = this.nextHookIndex
89+
this.nextHookIndex += 1
90+
this.transitionHooks[hookIndex] = hook
91+
return () => {
92+
delete this.transitionHooks[hookIndex]
93+
}
94+
},
95+
stateStorage: {
96+
save: this.saveScrollPosition,
97+
read: this.readScrollPosition,
98+
},
99+
getCurrentLocation: () => this.getCurrentLocation(),
100+
shouldUpdateScroll: (_, request) => {
101+
if (!this.options.shouldUpdateScroll) {
102+
return true // default behaviour
103+
}
104+
return this.options.shouldUpdateScroll(request)
105+
},
106+
})
107+
}
108+
109+
private getCurrentLocation = (): Location<Action> => {
110+
const location = this.api.getLocation()
111+
return {
112+
...location.entries[location.index],
113+
action: 'unknown',
114+
}
115+
}
116+
117+
saveScroll: Middleware<Action> = () => {
118+
return (request, next) => {
119+
this.lastRequest = request
120+
const { action } = request
121+
if (!('location' in action && action.location.prev)) {
122+
// If there is no previous location, there is no position to save
123+
return next()
124+
}
125+
Object.keys(this.transitionHooks).forEach((hookIndex) => {
126+
this.transitionHooks[hookIndex]()
127+
})
128+
return next()
129+
}
130+
}
131+
132+
restoreScroll: Middleware<Action> = () => {
133+
return (request, next) => {
134+
this.behavior.updateScroll(null, request)
135+
return next()
136+
}
137+
}
138+
139+
updateScroll = (): void => {
140+
if (this.lastRequest) {
141+
this.behavior.updateScroll(null, this.lastRequest)
142+
}
143+
}
144+
}
145+
146+
export default <Action extends FluxStandardRoutingAction>(
147+
options?: RestoreScrollOptions<Action>,
148+
): ScrollRestorerCreator<Action> => (api) =>
149+
new RudyScrollRestorer(api, options)
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../config/tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "src",
5+
"outDir": "ts"
6+
},
7+
"include": ["src"],
8+
"references": [{ "path": "../types" }, { "path": "../utils" }]
9+
}

packages/types/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@
2323
"prettier": "prettier",
2424
"is-pretty": "prettier --ignore-path=../../config/.prettierignore '**/*' --list-different",
2525
"prettify": "prettier --ignore-path=../../config/.prettierignore '**/*' --write"
26+
},
27+
"dependencies": {
28+
"scroll-behavior": "git+https://github.com/hedgepigdaniel/scroll-behavior.git#build/intermediate-scroll"
2629
}
2730
}

packages/types/src/index.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* and the outside world.
44
*/
55

6+
import { ScrollPosition } from 'scroll-behavior'
7+
68
/**
79
* Standard interface for redux actions which map to URLs/routes
810
*/
@@ -102,12 +104,14 @@ export type Location<Action extends FluxStandardRoutingAction> = LocationAction<
102104
*/
103105
export interface Api<Action extends FluxStandardRoutingAction> {
104106
getLocation: () => Location<Action>
107+
scrollRestorer?: ScrollRestorer<Action>
108+
options: Options<Action>
105109
}
106110

107111
/**
108112
* A Rudy request, associated with the dispatch of an FSRA
109113
*/
110-
export type Request<Action extends FluxStandardRoutingAction> = {
114+
export type Request<Action extends FluxStandardRoutingAction> = Api<Action> & {
111115
/**
112116
* The redux action corresponding to the request. Before the
113117
* transformAction middleware, the action is an `Action`, whereas
@@ -150,6 +154,20 @@ export type Middleware<Action extends FluxStandardRoutingAction> = (
150154
next: () => MiddlewareResult<Action>,
151155
) => MiddlewareResult<Action>
152156

157+
export interface ScrollRestorer<Action extends FluxStandardRoutingAction> {
158+
saveScroll: Middleware<Action>
159+
restoreScroll: Middleware<Action>
160+
updateScroll: () => void
161+
readScrollPosition: (
162+
entry: LocationEntry<Action>,
163+
key: string | null,
164+
) => ScrollPosition | null
165+
}
166+
167+
export type ScrollRestorerCreator<Action extends FluxStandardRoutingAction> = (
168+
api: Api<Action>,
169+
) => ScrollRestorer<Action>
170+
153171
/**
154172
* Options for a route, corresponding to FSRAs with a specific Redux action type
155173
*/
@@ -158,4 +176,6 @@ export type Route = {}
158176
/**
159177
* Global Rudy options
160178
*/
161-
export type Options = {}
179+
export type Options<Action extends FluxStandardRoutingAction> = {
180+
restoreScroll?: ScrollRestorerCreator<Action>
181+
}

0 commit comments

Comments
 (0)