You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Can the client router's scroll and history handling be reduced to something
close to Next's, which "just works", without re-opening any client-router
regression already fixed?
Prompted by #1428, where the iOS back-swipe preview is blank. That turned out
not to be a bug in the swap at all, but a downstream consequence of one
architectural decision, and the same decision is the source of most of the
complexity in the scroll path.
The finding, measured
Three frameworks, one real iPhone, same gesture, plus a local reproduction of
the WebJs case served from a branch build.
One property explains all five rows. Under auto WebKit records a scroll
position per history entry and composes the gesture preview from it. Under manual the app takes that over, the browser records nothing, and the preview
has no scroll state to render against.
?scrolllast, which defers the scroll-to-top and therefore tests the CLAMP that #1406 built its case on, came back negative. So it is not the scroll-to-top
clamping the offset. It is the browser not recording an offset at all.
#1410 was not the fix and could not have been. It shipped Turbo's exact
ordering, and Turbo blanks. The ordering change stands on its own merits and is
not being reverted, but it was aimed at the wrong mechanism.
Why manual is there
Commit 231cd8f8, 17 May 2026, states it:
history.scrollRestoration defaults to 'auto', so the browser tries to
restore scroll on popstate at the same time our SPA does. The two race;
whichever runs last wins; the user sees the correct cached scroll briefly,
then it jumps somewhere else as the second write lands.
So manual silences a race between TWO scroll writers, and the commit says the
shape was copied from Turbo's assumeControlOfScrollRestoration.
Why Next needs no equivalent
Next has one writer.handlePopState dispatches a restore and nothing else.
The restore reducer sets scrollRef: null
(router-reducer/reducers/restore-reducer.ts:54), and InnerScrollHandler
opens with if (scrollRef === null || !scrollRef.current) return
(layout-router.tsx:151-156). Next never scrolls on popstate. With one writer
there is no race, so there is nothing to silence, so auto survives, so the
preview works.
Worth recording because it was our first wrong answer: the <Activity> bfcache
is NOT the explanation. It is gated behind process.env.__NEXT_CACHE_COMPONENTS
(layout-router.tsx:754) and MAX_BF_CACHE_ENTRIES is 1 without that flag, so
default Next preserves no inactive trees.
The actual root cause in WebJs
WebJs cannot simply drop its own restore, because its restore is TWO-PHASE:
insert the snapshot HTML (short), then let custom elements upgrade and re-render
(taller). Native restoration lands against the short version. Next's back render
is single-phase, one React commit from its client cache, so the height is right
when the browser restores.
That single fact is upstream of everything:
manual, to stop the browser fighting a restore we had to own
the blank gesture preview, because manual stops the recording
scroll.js, 259 lines of anchoring suppression, catch-up chase, restore
generations, and two-frame deferral
nav-scroll-anchor-restore.test.js, 861 lines and 23 tests, every one of them
a rule about growth that arrives late
The problem is not scroll-restoration policy. It is that the restored DOM
changes height after insertion.
The proposed architecture
Record the settled height in the snapshot and reserve it across the swap.
snapshot-cache.js:11 currently records { html, scrollX, scrollY }. Add the
settled scrollHeight. On a popstate restore, reserve that height on the
document before applySwap, then release it once the upgrade settles.
Sequence, for a same-document traversal:
The browser restores scroll as part of the traversal, against the outgoing
document, which is still tall enough for the offset to be reachable
popstate fires, and the router reserves the recorded height
applySwap inserts the shorter incoming DOM, which cannot clamp the scroll,
because the reservation holds the height
Components upgrade, the real content reaches the recorded height, and the
reservation is released
Consequences:
Native auto restoration lands correctly, so the router needs no restore of
its own, so there is ONE writer, exactly Next's property
No, but it has a scroll assertion, so it is on the verify list
The caveat, stated plainly. "Not regressing previously fixed regressions"
means preserving the BEHAVIOURS, not the tests. The 23 tests in nav-scroll-anchor-restore.test.js mostly assert the MECHANISM (anchoring
windows opening and closing, catch-up chases, restore generations), and that
mechanism would no longer exist. So they cannot all be kept. The migration is:
keep and re-point the OUTCOME tests, which is that a reader returning to a page
that grows late lands at the recorded offset, and delete the ones that assert
machinery being removed. Anyone reviewing this must be told that a shrinking
test count here is the intended result and not lost coverage.
Risks, none of them settled yet
Browser restore timing is an assumption. The design assumes the browser
restores as part of the traversal, before popstate, against the outgoing
document. That is the load-bearing claim and it is not yet measured.
A changed viewport between visits (a rotated phone, a resized window)
makes the recorded height wrong. Release-on-settle bounds the damage, but the
reservation needs a sane cap.
A page that legitimately shrinks must not be held tall past settle.
Decision
Pursue the height reservation. It is the only option that removes the class of
bug rather than managing it, it is small, it fits the existing snapshot
structure, and it converges on Next's one-writer property, which is what "just
works" actually means here.
Two alternatives were weighed and rejected:
A transient flip (auto normally, manual only across the restore) treats
the symptom. It keeps both writers, keeps all 259 lines of scroll.js, and adds
a per-entry inheritance hazard, since history.scrollRestoration is a property
of the current entry and a new entry inherits it, so a flip that is not restored
before the next forward navigation silently brings the blank back on the second
nav. Kept as the fallback if the reservation fails on-device.
A live-DOM bfcache (keep the outgoing nodes, re-show on Back) would work and
is what Activity does, but it fights the web-components model. Detaching a
custom element fires disconnectedCallback and re-attaching fires connectedCallback, so every component author inherits lifecycle churn, and
holding two pages in one document invites duplicate ids plus focus and form
problems.
How this gets verified, given the record
Four hypotheses in this investigation were wrong before being measured, and two
device rounds were void because the setup was not verified first. So the
reservation ships as a guarded lever, gets the #1310 guards run under it across
Chromium, Firefox, and WebKit locally, and gets one on-device cell for the
preview, before a single line of it becomes default behaviour.
The question
Can the client router's scroll and history handling be reduced to something
close to Next's, which "just works", without re-opening any client-router
regression already fixed?
Prompted by #1428, where the iOS back-swipe preview is blank. That turned out
not to be a bug in the swap at all, but a downstream consequence of one
architectural decision, and the same decision is the source of most of the
complexity in the scroll path.
The finding, measured
Three frameworks, one real iPhone, same gesture, plus a local reproduction of
the WebJs case served from a branch build.
scrollRestorationmanualmanualmanualauto?scrollautoautoOne property explains all five rows. Under
autoWebKit records a scrollposition per history entry and composes the gesture preview from it. Under
manualthe app takes that over, the browser records nothing, and the previewhas no scroll state to render against.
?scrolllast, which defers the scroll-to-top and therefore tests the CLAMP that#1406 built its case on, came back negative. So it is not the scroll-to-top
clamping the offset. It is the browser not recording an offset at all.
#1410 was not the fix and could not have been. It shipped Turbo's exact
ordering, and Turbo blanks. The ordering change stands on its own merits and is
not being reverted, but it was aimed at the wrong mechanism.
Why
manualis thereCommit
231cd8f8, 17 May 2026, states it:So
manualsilences a race between TWO scroll writers, and the commit says theshape was copied from Turbo's
assumeControlOfScrollRestoration.Why Next needs no equivalent
Next has one writer.
handlePopStatedispatches a restore and nothing else.The restore reducer sets
scrollRef: null(
router-reducer/reducers/restore-reducer.ts:54), andInnerScrollHandleropens with
if (scrollRef === null || !scrollRef.current) return(
layout-router.tsx:151-156). Next never scrolls on popstate. With one writerthere is no race, so there is nothing to silence, so
autosurvives, so thepreview works.
Worth recording because it was our first wrong answer: the
<Activity>bfcacheis NOT the explanation. It is gated behind
process.env.__NEXT_CACHE_COMPONENTS(
layout-router.tsx:754) andMAX_BF_CACHE_ENTRIESis 1 without that flag, sodefault Next preserves no inactive trees.
The actual root cause in WebJs
WebJs cannot simply drop its own restore, because its restore is TWO-PHASE:
insert the snapshot HTML (short), then let custom elements upgrade and re-render
(taller). Native restoration lands against the short version. Next's back render
is single-phase, one React commit from its client cache, so the height is right
when the browser restores.
That single fact is upstream of everything:
manual, to stop the browser fighting a restore we had to ownmanualstops the recordingscroll.js, 259 lines of anchoring suppression, catch-up chase, restoregenerations, and two-frame deferral
nav-scroll-anchor-restore.test.js, 861 lines and 23 tests, every one of thema rule about growth that arrives late
The problem is not scroll-restoration policy. It is that the restored DOM
changes height after insertion.
The proposed architecture
Record the settled height in the snapshot and reserve it across the swap.
snapshot-cache.js:11currently records{ html, scrollX, scrollY }. Add thesettled
scrollHeight. On a popstate restore, reserve that height on thedocument before
applySwap, then release it once the upgrade settles.Sequence, for a same-document traversal:
document, which is still tall enough for the offset to be reachable
popstatefires, and the router reserves the recorded heightapplySwapinserts the shorter incoming DOM, which cannot clamp the scroll,because the reservation holds the height
reservation is released
Consequences:
autorestoration lands correctly, so the router needs no restore ofits own, so there is ONE writer, exactly Next's property
manualis no longer needed, so WebKit records per-entry offsets again, sothe gesture preview is fixed, which closes dogfood: iOS back-swipe still blank after #1410; A/B the snapshot timing on-device #1428 and dogfood: iOS back-swipe gesture flashes a blank page (client router) #641
the document is short. dogfood: back-button scroll restores ~763px too low on pages that grow after swap #1310 and fix: back-button restore survives late layout growth #1313 become structurally impossible rather
than continuously managed
scroll.jsand the restore branch innavigator.jscan goWhat this must not break, and whether it does
Every client-router regression with a live guard, checked against the proposal.
scroll-behavior: smoothposition: fixedrefreshPagepreserves scroll, records no entryThe caveat, stated plainly. "Not regressing previously fixed regressions"
means preserving the BEHAVIOURS, not the tests. The 23 tests in
nav-scroll-anchor-restore.test.jsmostly assert the MECHANISM (anchoringwindows opening and closing, catch-up chases, restore generations), and that
mechanism would no longer exist. So they cannot all be kept. The migration is:
keep and re-point the OUTCOME tests, which is that a reader returning to a page
that grows late lands at the recorded offset, and delete the ones that assert
machinery being removed. Anyone reviewing this must be told that a shrinking
test count here is the intended result and not lost coverage.
Risks, none of them settled yet
restores as part of the traversal, before
popstate, against the outgoingdocument. That is the load-bearing claim and it is not yet measured.
makes the recorded height wrong. Release-on-settle bounds the damage, but the
reservation needs a sane cap.
min-heighton the wrong element interactswith sticky and fixed positioning, which is exactly the neighbourhood of dogfood: mobile navbar flickers on forward nav (backdrop-blur sticky header) #610.
Decision
Pursue the height reservation. It is the only option that removes the class of
bug rather than managing it, it is small, it fits the existing snapshot
structure, and it converges on Next's one-writer property, which is what "just
works" actually means here.
Two alternatives were weighed and rejected:
A transient flip (
autonormally,manualonly across the restore) treatsthe symptom. It keeps both writers, keeps all 259 lines of
scroll.js, and addsa per-entry inheritance hazard, since
history.scrollRestorationis a propertyof the current entry and a new entry inherits it, so a flip that is not restored
before the next forward navigation silently brings the blank back on the second
nav. Kept as the fallback if the reservation fails on-device.
A live-DOM bfcache (keep the outgoing nodes, re-show on Back) would work and
is what Activity does, but it fights the web-components model. Detaching a
custom element fires
disconnectedCallbackand re-attaching firesconnectedCallback, so every component author inherits lifecycle churn, andholding two pages in one document invites duplicate ids plus focus and form
problems.
How this gets verified, given the record
Four hypotheses in this investigation were wrong before being measured, and two
device rounds were void because the setup was not verified first. So the
reservation ships as a guarded lever, gets the #1310 guards run under it across
Chromium, Firefox, and WebKit locally, and gets one on-device cell for the
preview, before a single line of it becomes default behaviour.
The implementation is separate tracked work.