Skip to content

Commit a578cc8

Browse files
authored
fix inconsistent scroll restoration behavior (#59366)
### What? While scrolled on a page, and when following a link to a new page and clicking the browser back button or using `router.back()`, the scroll position would sometimes restore scroll to the incorrect spot (in the case of the test added in this PR, it'd scroll you back to the top of the list) ### Why? The refactor in #56497 changed the way router actions are processed: specifically, all actions were assumed to be async, even if they could be handled synchronously. For most actions this is fine, as most are currently async. However, `ACTION_RESTORE` (triggered when the `popstate` event occurs) isn't async, and introducing a small amount of delay in the handling of this action can cause the browser to not properly restore the scroll position ### How? This special-cases `ACTION_RESTORE` to synchronously process the action and call `setState` when it's received, rather than creating a promise. To consistently reproduce this behavior, I added an option to our browser interface that'll allow us to programmatically trigger a CPU slowdown. h/t to @alvarlagerlof for isolating the offending commit and sharing a minimal reproduction. Closes NEXT-1819 Likely addresses #58899 but the reproduction was too complex to verify.
1 parent 2874bc0 commit a578cc8

File tree

9 files changed

+151
-16
lines changed

9 files changed

+151
-16
lines changed

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ACTION_REFRESH,
77
ACTION_SERVER_ACTION,
88
ACTION_NAVIGATE,
9+
ACTION_RESTORE,
910
} from '../../../client/components/router-reducer/router-reducer-types'
1011
import type { ReduxDevToolsInstance } from '../../../client/components/use-reducer-with-devtools'
1112
import { reducer } from '../../../client/components/router-reducer/router-reducer'
@@ -26,7 +27,7 @@ export type AppRouterActionQueue = {
2627
export type ActionQueueNode = {
2728
payload: ReducerActions
2829
next: ActionQueueNode | null
29-
resolve: (value: PromiseLike<AppRouterState> | AppRouterState) => void
30+
resolve: (value: ReducerState) => void
3031
reject: (err: Error) => void
3132
discarded?: boolean
3233
}
@@ -115,14 +116,26 @@ function dispatchAction(
115116
setState: DispatchStatePromise
116117
) {
117118
let resolvers: {
118-
resolve: (value: AppRouterState | PromiseLike<AppRouterState>) => void
119+
resolve: (value: ReducerState) => void
119120
reject: (reason: any) => void
120-
}
121+
} = { resolve: setState, reject: () => {} }
122+
123+
// most of the action types are async with the exception of restore
124+
// it's important that restore is handled quickly since it's fired on the popstate event
125+
// and we don't want to add any delay on a back/forward nav
126+
// this only creates a promise for the async actions
127+
if (payload.type !== ACTION_RESTORE) {
128+
// Create the promise and assign the resolvers to the object.
129+
const deferredPromise = new Promise<AppRouterState>((resolve, reject) => {
130+
resolvers = { resolve, reject }
131+
})
121132

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

127140
const newAction: ActionQueueNode = {
128141
payload,
@@ -131,12 +144,6 @@ function dispatchAction(
131144
reject: resolvers!.reject,
132145
}
133146

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-
140147
// Check if the queue is empty
141148
if (actionQueue.pending === null) {
142149
// The queue is empty, so add the action and start it immediately
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createContext } from 'react'
2+
3+
export interface Item {
4+
id: number
5+
}
6+
7+
export const ItemsContext = createContext<{
8+
items: Item[]
9+
loadMoreItems: () => void
10+
}>({ items: [], loadMoreItems: () => {} })
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
import { useState } from 'react'
3+
import { ItemsContext, type Item } from './context'
4+
5+
const createItems = (start: number, end: number): Item[] => {
6+
const items: Item[] = []
7+
for (let i = start; i <= end; i++) {
8+
items.push({ id: i })
9+
}
10+
return items
11+
}
12+
13+
export default function Layout({ children }: { children: React.ReactNode }) {
14+
const [items, setItems] = useState<Item[]>(createItems(1, 50))
15+
16+
const loadMoreItems = () => {
17+
const start = items.length + 1
18+
const end = start + 50 - 1
19+
setItems((prevItems) => [...prevItems, ...createItems(start, end)])
20+
}
21+
22+
return (
23+
<ItemsContext.Provider value={{ items, loadMoreItems }}>
24+
{children}
25+
</ItemsContext.Provider>
26+
)
27+
}
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 } from 'react'
4+
import { ItemsContext } from './context'
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)