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
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #98323
What?
Two same-URL re-render paths in the client router hard-code
ScrollBehavior.Defaultand can therefore mint a liveScrollReffor a page that is already on screen, whichlayout-routerthen obeys by writingdocumentElement.scrollTop = 0:serverPatchReducer— the tree-mismatch retry. NowNoScrollwhen the retry's canonical URL is the URL already shown (the retry is completing a refresh).serverActionReducer— an action that revalidated without redirecting. NowNoScrollunless the action redirected (a redirect is the one case that is a real navigation).Why?
router.refresh()is dispatched withNoScroll, 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')—restoreReduceradopts the newcanonicalUrlbut keeps the previousrenderedSearch, so the next refresh predicts__PAGE__, the server answers__PAGE__?{"open":"1"},finishNavigationTaskreports a mismatch, and the retry re-navigates withDefault. The page leaf diverges,accumulateScrollRefassigns a live ref, and the page jumps to the top. A revalidating server action after the samereplaceStatefails the same way through its ownDefault.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
replaceStatestep 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
scrollBehavioron 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-autoscrollon a new/replace-state-refreshpage —replaceStatethenrouter.refresh(), andreplaceStatethen a revalidating server action — both asserting the position holds aty: 1000. They mirror theserver-action-refreshtest from #91348.Verified as a patch on
next@16.2.3against the reproduction above and against our app's own Playwright suite, where the same sequence (drawer opened viareplaceState, 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.