vercel / vercel/next.js

router.refresh() scrolls to top after history.replaceState changed the search params (tree-mismatch retry uses ScrollBehavior.Default)

Open
#98,323 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Linking and Navigating
Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/onyxdevs/next-replacestate-refresh-scroll

To Reproduce
  1. npm install && npm run build && npm run start (port 4612; next dev is affected the same way).
  2. Open http://localhost:4612, scroll down a few screens.
  3. Click 1window.history.replaceState(null, '', '?open=1'), the documented native History API pattern. useSearchParams updates, the page does not move.
  4. Click 2router.refresh().

The page jumps to the top. npm run probe is a Playwright script that does exactly this and prints every write to documentElement.scrollTop that moved the page, with its caller; npm run probe -- --control skips step 3 and passes.

The repro is 6 tiny files: a root layout, one force-dynamic page tall enough to scroll, a client component with the two buttons (plus a third that runs a no-op server action followed by router.refresh(), which behaves the same), and a no-op server action.

Current vs. Expected behavior

Current (16.2.3, 16.3.4, 16.4.0-canary.19, production build, default config): after a history.replaceState that changed the search params, the next router.refresh() scrolls the document to the top. The probe's output on all three versions:

scrolled to 800
after replaceState 800 ?open=1
after router.refresh() 0
document writes that MOVED the page: [ { "target": "HTML.scrollTop=0", stack: "… handlePotentialScroll … componentDidUpdate" } ]

Control (no replaceState first): after router.refresh() 800, no writes.

Expected: router.refresh() never scrolls — it is dispatched with ScrollBehavior.NoScroll, and #91348 states the model as "refresh creates no new CacheNodes, so no ScrollRef is assigned, and nothing scrolls".

Provide environment information
Operating System:
  Platform: linux
  Arch: x64
  Version: #1 SMP PREEMPT_DYNAMIC Debian 6.1.119-1 (2024-11-22)
  Available memory (MB): 128725
  Available CPU cores: 12
Binaries:
  Node: 22.23.1
  npm: 10.9.8
  Yarn: 1.22.22
  pnpm: 12.3.4
Relevant Packages:
  next: 16.2.3 (also reproduced on 16.3.4 and 16.4.0-canary.19)
  eslint-config-next: N/A
  react: 19.2.0
  react-dom: 19.2.0
  typescript: N/A
Next.js Config:
  output: N/A
Which area(s) are affected? (Select all that apply)

Linking and Navigating, Server Actions

Which stage(s) are affected? (Select all that apply)

next start (local), next dev (local)

Additional context — where the scroll comes from

Read out of the 16.2.3 client bundle and confirmed against the canary sources:

  1. The patched history.replaceState dispatches ACTION_RESTORE with window.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE, which HistoryUpdater wrote at the last commit — so restoreReducer adopts the NEW canonicalUrl but keeps the OLD renderedSearch and tree.
  2. router.refresh()refreshDynamicData builds its seed from state.tree + state.renderedSearch (old search) and fetches state.canonicalUrl (new search). The predicted page segment key is __PAGE__; the server answers __PAGE__?{"open":"1"} (addSearchParamsIfPageSegment). finishNavigationTask sees a tree mismatch and calls dispatchRetryDueToTreeMismatch.
  3. serverPatchReducer (the retry) hard-codes scrollBehavior = ScrollBehavior.Default. The retry's route tree diverges from state.tree at the page leaf, so createCacheNodeOnNavigationaccumulateScrollRef assigns a live ScrollRef to the new page node, and — unlike the original refresh, which was NoScroll — nothing neutralises it.
  4. layout-router's handlePotentialScroll runs on the commit, finds the ref live, and writes documentElement.scrollTop = 0 (and focuses the page's first element).

So the retry drops the original navigation's scroll intent: a refresh that has to retry scrolls, while a refresh that does not retry correctly stays put. The same hard-coded ScrollBehavior.Default exists in serverActionReducer for an action that did NOT redirect — a same-URL re-render — which is why the "server action + refresh" button in the repro behaves identically.

A two-line fix that resolves the repro (verified locally as a patch on 16.2.3, plus our own app's Playwright suite): in server-patch-reducer.ts use NoScroll when retryCanonicalUrl === state.canonicalUrl (a retry aimed at the URL already on screen is a refresh), and in server-action-reducer.ts use NoScroll when redirectLocation === undefined. The more principled version is for the retry action to carry the original navigation's scrollBehavior, which is likely also what #98021 needs (the opposite symptom on the same path: a retry that should scroll and does not). Happy to open a PR either way.

Why this matters in practice: every app that follows the documented replaceState pattern for drawer/filter state and then does a mutation + router.refresh() (or a revalidating server action) gets a page jump on the first refresh after each URL write — and only when the document is scrolled, which makes it look intermittent. It took us three fix attempts in app code before instrumenting the scrollTop setter.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the repro and run npm run probe with and without --control to confirm the scroll difference. Then read server-patch-reducer.ts and server-action-reducer.ts, tracing how retry and same-URL action paths set scroll behavior; done means the probe keeps the document at its existing scroll position after replaceState followed by refresh.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, next.js, react
Domain
frontend, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.