Skip to content

fix(router): preserve scroll:false across async PPR retry navigations (force-dynamic)#94943

Open
sleitor wants to merge 1 commit into
vercel:canaryfrom
sleitor:fix-94893
Open

fix(router): preserve scroll:false across async PPR retry navigations (force-dynamic)#94943
sleitor wants to merge 1 commit into
vercel:canaryfrom
sleitor:fix-94893

Conversation

@sleitor

@sleitor sleitor commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What?

Thread scrollBehavior through the async PPR retry chain so that scroll: false passed to router.replace() (or router.push()) is preserved when a force-dynamic page triggers a tree mismatch and the navigation falls back to ACTION_SERVER_PATCH.

Why?

On force-dynamic pages with PPR enabled, every navigation that hits an optimistic route cache entry ends up in a tree mismatch → dispatchRetryDueToTreeMismatchserverPatchReducer. Before this fix, serverPatchReducer hardcoded ScrollBehavior.Default regardless of the original navigation's scroll: false option, causing the page to scroll back to the top on every navigation.

The bug only triggers in production (prefetching is disabled in dev) and only after the user has prefetched the root route (e.g. by hovering over a <Link href="/">), which is why it's subtle.

How?

  1. Add optional scrollBehavior?: ScrollBehavior to ServerPatchAction in router-reducer-types.ts.
  2. Add scrollBehavior parameter to spawnDynamicRequests() (after navigationLock).
  3. Thread it through: spawnDynamicRequestsfinishNavigationTaskdispatchRetryDueToTreeMismatchServerPatchAction.scrollBehavior.
  4. In serverPatchReducer, read action.scrollBehavior ?? ScrollBehavior.Default instead of always using Default.
  5. Update restore-reducer.ts (the only other caller of spawnDynamicRequests) to pass ScrollBehavior.Default — history traversal already defaults to no-scroll behavior.

Fixes #94893

When a force-dynamic page triggers a tree mismatch during a PPR
navigation, the retry (ACTION_SERVER_PATCH) was hardcoding
ScrollBehavior.Default, discarding the original scroll:false option.

Thread scrollBehavior through the async retry chain:
- spawnDynamicRequests() → finishNavigationTask()
- finishNavigationTask() → dispatchRetryDueToTreeMismatch()
- dispatchRetryDueToTreeMismatch() → ServerPatchAction.scrollBehavior
- serverPatchReducer() reads action.scrollBehavior ?? Default

Also update restore-reducer.ts which also calls spawnDynamicRequests()
(history traversal, passes ScrollBehavior.Default).

Fixes vercel#94893
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scroll: false not preserved across async PPR retry navigations (force-dynamic pages)

1 participant