router.refresh() scrolls to top after history.replaceState changed the search params (tree-mismatch retry uses ScrollBehavior.Default)
Nobody has claimed this yet.
- 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
npm install && npm run build && npm run start(port 4612;next devis affected the same way).- Open
http://localhost:4612, scroll down a few screens. - Click 1 —
window.history.replaceState(null, '', '?open=1'), the documented native History API pattern.useSearchParamsupdates, the page does not move. - Click 2 —
router.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:
- The patched
history.replaceStatedispatchesACTION_RESTOREwithwindow.history.state.__PRIVATE_NEXTJS_INTERNALS_TREE, whichHistoryUpdaterwrote at the last commit — sorestoreReduceradopts the NEWcanonicalUrlbut keeps the OLDrenderedSearchand tree. router.refresh()→refreshDynamicDatabuilds its seed fromstate.tree+state.renderedSearch(old search) and fetchesstate.canonicalUrl(new search). The predicted page segment key is__PAGE__; the server answers__PAGE__?{"open":"1"}(addSearchParamsIfPageSegment).finishNavigationTasksees a tree mismatch and callsdispatchRetryDueToTreeMismatch.serverPatchReducer(the retry) hard-codesscrollBehavior = ScrollBehavior.Default. The retry's route tree diverges fromstate.treeat the page leaf, socreateCacheNodeOnNavigation→accumulateScrollRefassigns a liveScrollRefto the new page node, and — unlike the original refresh, which wasNoScroll— nothing neutralises it.layout-router'shandlePotentialScrollruns on the commit, finds the ref live, and writesdocumentElement.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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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