Skip to content

Commit aafce2b

Browse files
committed
fix inconsistent scroll restoration behavior
1 parent 19fa1fa commit aafce2b

File tree

8 files changed

+148
-16
lines changed

8 files changed

+148
-16
lines changed

packages/next/src/shared/lib/router/action-queue.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type AppRouterActionQueue = {
2626
export type ActionQueueNode = {
2727
payload: ReducerActions
2828
next: ActionQueueNode | null
29-
resolve: (value: PromiseLike<AppRouterState> | AppRouterState) => void
29+
resolve: (value: ReducerState) => void
3030
reject: (err: Error) => void
3131
discarded?: boolean
3232
}
@@ -115,14 +115,26 @@ function dispatchAction(
115115
setState: DispatchStatePromise
116116
) {
117117
let resolvers: {
118-
resolve: (value: AppRouterState | PromiseLike<AppRouterState>) => void
118+
resolve: (value: ReducerState) => void
119119
reject: (reason: any) => void
120-
}
120+
} = { resolve: setState, reject: () => {} }
121+
122+
// most of the action types are async with the exception of restore
123+
// it's important that restore is handled quickly since it's fired on the popstate event
124+
// and we don't want to add any delay on a back/forward nav
125+
// this only creates a promise for the async actions
126+
if (payload.type !== 'restore') {
127+
// Create the promise and assign the resolvers to the object.
128+
const deferredPromise = new Promise<AppRouterState>((resolve, reject) => {
129+
resolvers = { resolve, reject }
130+
})
121131

122-
// Create the promise and assign the resolvers to the object.
123-
const deferredPromise = new Promise<AppRouterState>((resolve, reject) => {
124-
resolvers = { resolve, reject }
125-
})
132+
startTransition(() => {
133+
// we immediately notify React of the pending promise -- the resolver is attached to the action node
134+
// and will be called when the associated action promise resolves
135+
setState(deferredPromise)
136+
})
137+
}
126138

127139
const newAction: ActionQueueNode = {
128140
payload,
@@ -131,12 +143,6 @@ function dispatchAction(
131143
reject: resolvers!.reject,
132144
}
133145

134-
startTransition(() => {
135-
// we immediately notify React of the pending promise -- the resolver is attached to the action node
136-
// and will be called when the associated action promise resolves
137-
setState(deferredPromise)
138-
})
139-
140146
// Check if the queue is empty
141147
if (actionQueue.pending === null) {
142148
// The queue is empty, so add the action and start it immediately
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client'
2+
import { useState, createContext } from 'react'
3+
4+
interface Item {
5+
id: number
6+
}
7+
8+
export const ItemsContext = createContext<{
9+
items: Item[]
10+
loadMoreItems: () => void
11+
}>({ items: [], loadMoreItems: () => {} })
12+
13+
const createItems = (start: number, end: number): Item[] => {
14+
const items: Item[] = []
15+
for (let i = start; i <= end; i++) {
16+
items.push({ id: i })
17+
}
18+
return items
19+
}
20+
21+
export default function Layout({ children }: { children: React.ReactNode }) {
22+
const [items, setItems] = useState<Item[]>(createItems(1, 50))
23+
24+
const loadMoreItems = () => {
25+
const start = items.length + 1
26+
const end = start + 50 - 1
27+
setItems((prevItems) => [...prevItems, ...createItems(start, end)])
28+
}
29+
30+
return (
31+
<ItemsContext.Provider value={{ items, loadMoreItems }}>
32+
{children}
33+
</ItemsContext.Provider>
34+
)
35+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
import { useRouter } from 'next/navigation'
3+
4+
export default function Page() {
5+
const router = useRouter()
6+
return (
7+
<div>
8+
<button onClick={() => router.back()} id="back-button">
9+
Go Back
10+
</button>
11+
</div>
12+
)
13+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use client'
2+
import Link from 'next/link'
3+
import React, { useContext, useState } from 'react'
4+
import { ItemsContext } from './layout'
5+
6+
export default function Page() {
7+
const { items, loadMoreItems } = useContext(ItemsContext)
8+
9+
return (
10+
<div>
11+
<h1>Page</h1>
12+
<ul>
13+
{items.map((item) => (
14+
<li key={item.id}>Item {item.id}</li>
15+
))}
16+
</ul>
17+
<button id="load-more" onClick={loadMoreItems}>
18+
Load More
19+
</button>
20+
<Link href="/scroll-restoration/other">Go to Other</Link>
21+
</div>
22+
)
23+
}

test/e2e/app-dir/navigation/navigation.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,5 +732,34 @@ createNextDescribe(
732732
}
733733
})
734734
})
735+
736+
describe('scroll restoration', () => {
737+
it('should restore original scroll position when navigating back', async () => {
738+
const browser = await next.browser('/scroll-restoration', {
739+
// throttling the CPU to rule out flakiness based on how quickly the page loads
740+
cpuThrottleRate: 6,
741+
})
742+
const body = await browser.elementByCss('body')
743+
expect(await body.text()).toContain('Item 50')
744+
await browser.elementById('load-more').click()
745+
await browser.elementById('load-more').click()
746+
await browser.elementById('load-more').click()
747+
expect(await body.text()).toContain('Item 200')
748+
749+
// scroll to the bottom of the page
750+
await browser.eval('window.scrollTo(0, document.body.scrollHeight)')
751+
752+
// grab the current position
753+
const scrollPosition = await browser.eval('window.pageYOffset')
754+
755+
await browser.elementByCss("[href='/scroll-restoration/other']").click()
756+
await browser.elementById('back-button').click()
757+
758+
const newScrollPosition = await browser.eval('window.pageYOffset')
759+
760+
// confirm that the scroll position was restored
761+
await check(() => scrollPosition === newScrollPosition, true)
762+
})
763+
})
735764
}
736765
)

test/lib/browsers/base.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,15 @@ export abstract class BrowserInterface implements PromiseLike<any> {
125125
off(event: Event, cb: (...args: any[]) => void) {}
126126
async loadPage(
127127
url: string,
128-
{ disableCache: boolean, beforePageLoad: Function }
128+
{
129+
disableCache,
130+
cpuThrottleRate,
131+
beforePageLoad,
132+
}: {
133+
disableCache?: boolean
134+
cpuThrottleRate?: number
135+
beforePageLoad?: Function
136+
}
129137
): Promise<void> {}
130138
async get(url: string): Promise<void> {}
131139

test/lib/browsers/playwright.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ export class Playwright extends BrowserInterface {
128128

129129
async loadPage(
130130
url: string,
131-
opts?: { disableCache: boolean; beforePageLoad?: (...args: any[]) => void }
131+
opts?: {
132+
disableCache: boolean
133+
cpuThrottleRate: number
134+
beforePageLoad?: (...args: any[]) => void
135+
}
132136
) {
133137
if (this.activeTrace) {
134138
const traceDir = path.join(__dirname, '../../traces')
@@ -182,6 +186,14 @@ export class Playwright extends BrowserInterface {
182186
session.send('Network.setCacheDisabled', { cacheDisabled: true })
183187
}
184188

189+
if (opts.cpuThrottleRate) {
190+
const session = await context.newCDPSession(page)
191+
// https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setCPUThrottlingRate
192+
session.send('Emulation.setCPUThrottlingRate', {
193+
rate: opts.cpuThrottleRate,
194+
})
195+
}
196+
185197
page.on('websocket', (ws) => {
186198
if (tracePlaywright) {
187199
page

test/lib/next-webdriver.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export default async function webdriver(
6868
disableJavaScript?: boolean
6969
headless?: boolean
7070
ignoreHTTPSErrors?: boolean
71+
cpuThrottleRate?: number
7172
}
7273
): Promise<BrowserInterface> {
7374
let CurrentInterface: new () => BrowserInterface
@@ -87,6 +88,7 @@ export default async function webdriver(
8788
disableJavaScript,
8889
ignoreHTTPSErrors,
8990
headless,
91+
cpuThrottleRate,
9092
} = options
9193

9294
// we import only the needed interface
@@ -124,7 +126,11 @@ export default async function webdriver(
124126

125127
console.log(`\n> Loading browser with ${fullUrl}\n`)
126128

127-
await browser.loadPage(fullUrl, { disableCache, beforePageLoad })
129+
await browser.loadPage(fullUrl, {
130+
disableCache,
131+
cpuThrottleRate,
132+
beforePageLoad,
133+
})
128134
console.log(`\n> Loaded browser with ${fullUrl}\n`)
129135

130136
// Wait for application to hydrate

0 commit comments

Comments
 (0)