Skip to content

Commit 38bd1a0

Browse files
authored
Re-enable scroll restoration behind flag (#14046)
This updates the scroll position saving to occur as the scroll position changes instead of trying to do it when the navigation is changing since the `popState` event doesn't allow us to update the leaving history state once the `popState` has occurred. The order of events that was previously attempted to save scroll position on a `popState` event (back/forward navigation) 1. history.state is already updated with state from `popState` 2. we replace state with the currently rendered page adding scroll info 3. we replace state again with the `popState` event state overriding scroll info Using this approach the above event order is no longer in conflict since we don't attempt to populate the state with scroll position while it's leaving the state and instead do it while it is still the active state in history This approach resembles existing solutions: https://www.npmjs.com/package/scroll-behavior https://twitter.com/ryanflorence/status/1029121580855488512 Fixes: #13990 Fixes: #12530 x-ref: #14075
1 parent fdc4c17 commit 38bd1a0

File tree

12 files changed

+361
-1
lines changed

12 files changed

+361
-1
lines changed

packages/next/build/webpack-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,9 @@ export default async function getBaseWebpackConfig(
863863
'process.env.__NEXT_REACT_MODE': JSON.stringify(
864864
config.experimental.reactMode
865865
),
866+
'process.env.__NEXT_SCROLL_RESTORATION': JSON.stringify(
867+
config.experimental.scrollRestoration
868+
),
866869
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
867870
...(isServer
868871
? {

packages/next/next-server/lib/router/router.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ type ComponentLoadCancel = (() => void) | null
9797

9898
type HistoryMethod = 'replaceState' | 'pushState'
9999

100+
const manualScrollRestoration =
101+
process.env.__NEXT_SCROLL_RESTORATION &&
102+
typeof window !== 'undefined' &&
103+
'scrollRestoration' in window.history
104+
100105
function fetchNextData(
101106
dataHref: string,
102107
isServerRender: boolean,
@@ -250,6 +255,35 @@ export default class Router implements BaseRouter {
250255
}
251256

252257
window.addEventListener('popstate', this.onPopState)
258+
259+
// enable custom scroll restoration handling when available
260+
// otherwise fallback to browser's default handling
261+
if (process.env.__NEXT_SCROLL_RESTORATION) {
262+
if (manualScrollRestoration) {
263+
window.history.scrollRestoration = 'manual'
264+
265+
let scrollDebounceTimeout: undefined | NodeJS.Timeout
266+
267+
const debouncedScrollSave = () => {
268+
if (scrollDebounceTimeout) clearTimeout(scrollDebounceTimeout)
269+
270+
scrollDebounceTimeout = setTimeout(() => {
271+
const { url, as: curAs, options } = history.state
272+
this.changeState(
273+
'replaceState',
274+
url,
275+
curAs,
276+
Object.assign({}, options, {
277+
_N_X: window.scrollX,
278+
_N_Y: window.scrollY,
279+
})
280+
)
281+
}, 10)
282+
}
283+
284+
window.addEventListener('scroll', debouncedScrollSave)
285+
}
286+
}
253287
}
254288
}
255289

@@ -503,7 +537,13 @@ export default class Router implements BaseRouter {
503537
throw error
504538
}
505539

540+
if (process.env.__NEXT_SCROLL_RESTORATION) {
541+
if (manualScrollRestoration && '_N_X' in options) {
542+
window.scrollTo(options._N_X, options._N_Y)
543+
}
544+
}
506545
Router.events.emit('routeChangeComplete', as)
546+
507547
return resolve(true)
508548
})
509549
},

packages/next/next-server/server/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const defaultConfig: { [key: string]: any } = {
5353
workerThreads: false,
5454
pageEnv: false,
5555
productionBrowserSourceMaps: false,
56+
scrollRestoration: false,
5657
},
5758
future: {
5859
excludeDefaultMomentLocales: false,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
experimental: {
3+
scrollRestoration: true,
4+
},
5+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Link from 'next/link'
2+
3+
export default () => (
4+
<>
5+
<p id="another">hi from another</p>
6+
<Link href="/">
7+
<a id="to-index">to index</a>
8+
</Link>
9+
</>
10+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Link from 'next/link'
2+
3+
const Page = ({ loaded }) => {
4+
const link = (
5+
<Link href="/another">
6+
<a
7+
id="to-another"
8+
style={{
9+
marginLeft: 5000,
10+
width: 95000,
11+
display: 'block',
12+
}}
13+
>
14+
to another
15+
</a>
16+
</Link>
17+
)
18+
19+
if (typeof window !== 'undefined') {
20+
window.didHydrate = true
21+
}
22+
23+
if (loaded) {
24+
return (
25+
<>
26+
<div
27+
style={{
28+
width: 10000,
29+
height: 10000,
30+
background: 'orange',
31+
}}
32+
/>
33+
{link}
34+
<div id="end-el">the end</div>
35+
</>
36+
)
37+
}
38+
39+
return link
40+
}
41+
42+
export default Page
43+
44+
export const getServerSideProps = () => {
45+
return {
46+
props: {
47+
loaded: true,
48+
},
49+
}
50+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/* eslint-env jest */
2+
3+
import { join } from 'path'
4+
import webdriver from 'next-webdriver'
5+
import {
6+
killApp,
7+
findPort,
8+
launchApp,
9+
nextStart,
10+
nextBuild,
11+
check,
12+
} from 'next-test-utils'
13+
14+
jest.setTimeout(1000 * 60 * 2)
15+
16+
const appDir = join(__dirname, '../')
17+
let appPort
18+
let app
19+
20+
const runTests = () => {
21+
it('should restore the scroll position on navigating back', async () => {
22+
const browser = await webdriver(appPort, '/')
23+
await browser.eval(() =>
24+
document.querySelector('#to-another').scrollIntoView()
25+
)
26+
const scrollRestoration = await browser.eval(
27+
() => window.history.scrollRestoration
28+
)
29+
30+
expect(scrollRestoration).toBe('manual')
31+
32+
const scrollX = Math.floor(await browser.eval(() => window.scrollX))
33+
const scrollY = Math.floor(await browser.eval(() => window.scrollY))
34+
35+
expect(scrollX).not.toBe(0)
36+
expect(scrollY).not.toBe(0)
37+
38+
await browser.eval(() => window.next.router.push('/another'))
39+
40+
await check(
41+
() => browser.eval(() => document.documentElement.innerHTML),
42+
/hi from another/
43+
)
44+
await browser.eval(() => (window.didHydrate = false))
45+
46+
await browser.eval(() => window.history.back())
47+
await check(() => browser.eval(() => window.didHydrate), {
48+
test(content) {
49+
return content
50+
},
51+
})
52+
53+
const newScrollX = Math.floor(await browser.eval(() => window.scrollX))
54+
const newScrollY = Math.floor(await browser.eval(() => window.scrollY))
55+
56+
console.log({
57+
scrollX,
58+
scrollY,
59+
newScrollX,
60+
newScrollY,
61+
})
62+
63+
expect(scrollX).toBe(newScrollX)
64+
expect(scrollY).toBe(newScrollY)
65+
})
66+
}
67+
68+
describe('Scroll Restoration Support', () => {
69+
describe('dev mode', () => {
70+
beforeAll(async () => {
71+
appPort = await findPort()
72+
app = await launchApp(appDir, appPort)
73+
})
74+
afterAll(() => killApp(app))
75+
76+
runTests()
77+
})
78+
79+
describe('server mode', () => {
80+
beforeAll(async () => {
81+
await nextBuild(appDir)
82+
appPort = await findPort()
83+
app = await nextStart(appDir, appPort)
84+
})
85+
afterAll(() => killApp(app))
86+
87+
runTests()
88+
})
89+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
experimental: {
3+
scrollRestoration: true,
4+
},
5+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Link from 'next/link'
2+
3+
export default () => (
4+
<>
5+
<p id="another">hi from another</p>
6+
<Link href="/">
7+
<a id="to-index">to index</a>
8+
</Link>
9+
</>
10+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Link from 'next/link'
2+
3+
const Page = ({ loaded }) => {
4+
const link = (
5+
<Link href="/another">
6+
<a
7+
id="to-another"
8+
style={{
9+
marginLeft: 5000,
10+
width: 95000,
11+
display: 'block',
12+
}}
13+
>
14+
to another
15+
</a>
16+
</Link>
17+
)
18+
19+
if (typeof window !== 'undefined') {
20+
window.didHydrate = true
21+
}
22+
23+
if (loaded) {
24+
return (
25+
<>
26+
<div
27+
style={{
28+
width: 10000,
29+
height: 10000,
30+
background: 'orange',
31+
}}
32+
/>
33+
{link}
34+
<div id="end-el">the end</div>
35+
</>
36+
)
37+
}
38+
39+
return link
40+
}
41+
42+
export default Page
43+
44+
export const getServerSideProps = () => {
45+
return {
46+
props: {
47+
loaded: true,
48+
},
49+
}
50+
}

0 commit comments

Comments
 (0)