Skip to content

fix(router): keep scroll position on a same-URL retry and on a non-redirecting server action - #98324

Open
onyxdevs wants to merge 5 commits into
vercel:canaryfrom
onyxdevs:fix/retry-and-action-keep-scroll-on-same-url
Open

fix(router): keep scroll position on a same-URL retry and on a non-redirecting server action#98324
onyxdevs wants to merge 5 commits into
vercel:canaryfrom
onyxdevs:fix/retry-and-action-keep-scroll-on-same-url

Conversation

@onyxdevs

@onyxdevs onyxdevs commented Sep 7, 2026

Copy link
Copy Markdown

Fixes #98323

What?

Two same-URL re-render paths in the client router hard-code ScrollBehavior.Default and can therefore mint a live ScrollRef for a page that is already on screen, which layout-router then obeys by writing documentElement.scrollTop = 0:

  • serverPatchReducer — the tree-mismatch retry. Now NoScroll when the retry's canonical URL is the URL already shown (the retry is completing a refresh).
  • serverActionReducer — an action that revalidated without redirecting. Now NoScroll unless the action redirected (a redirect is the one case that is a real navigation).

Why?

router.refresh() is dispatched with NoScroll, and #91348 sets the model as "refresh creates no new CacheNodes, so nothing scrolls". That holds only while the refresh does not have to retry. After the documented native History API pattern — window.history.replaceState(null, '', '?open=1')restoreReducer adopts the new canonicalUrl but keeps the previous renderedSearch, so the next refresh predicts __PAGE__, the server answers __PAGE__?{"open":"1"}, finishNavigationTask reports a mismatch, and the retry re-navigates with Default. The page leaf diverges, accumulateScrollRef assigns a live ref, and the page jumps to the top. A revalidating server action after the same replaceState fails the same way through its own Default.

Reproduction (6 files, Playwright probe printing the writer of every scroll that moved the page): https://github.com/onyxdevs/next-replacestate-refresh-scroll — fails on 16.2.3, 16.3.4 and 16.4.0-canary.19, passes with the replaceState step removed.

How?

The minimal fix at the two call sites, keeping every real navigation's scroll behaviour untouched: a retry for a different URL (a redirect discovered during the retry) still scrolls, and a redirecting action still scrolls. A follow-up could carry the original navigation's scrollBehavior on the retry action instead of inferring it from the URL, which is probably also what #98021 needs (a retry that should scroll and does not).

Tests: two cases added to router-autoscroll on a new /replace-state-refresh page — replaceState then router.refresh(), and replaceState then a revalidating server action — both asserting the position holds at y: 1000. They mirror the server-action-refresh test from #91348.

Verified as a patch on next@16.2.3 against the reproduction above and against our app's own Playwright suite, where the same sequence (drawer opened via replaceState, then a mutation + router.refresh()) went from a deterministic jump to a held position. I was not able to run the Next.js e2e harness locally, so the added tests are untested against it — happy to adjust.

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.

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

1 participant