Skip to content

research: client-router scroll architecture, remove the two-phase restore instead of managing it #1433

Description

@vivek7405

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.

push timing scrollRestoration gesture preview
WebJs pre-#1410 after the swap manual blank
WebJs post-#1410 before the swap manual blank
Turbo Drive before the swap, inside a rAF manual blank
Next App Router after commit auto clean
WebJs + ?scrollauto before the swap auto clean

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:

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:

  1. The browser restores scroll as part of the traversal, against the outgoing
    document, which is still tall enough for the offset to be reachable
  2. popstate fires, and the router reserves the recorded height
  3. applySwap inserts the shorter incoming DOM, which cannot clamp the scroll,
    because the reservation holds the height
  4. Components upgrade, the real content reaches the recorded height, and the
    reservation is released

Consequences:

What this must not break, and whether it does

Every client-router regression with a live guard, checked against the proposal.

Issue What it guards Affected?
#1310 / #1313 back restore survives late layout growth Mechanism replaced, guarantee preserved. See caveat below
#1406 / #1410 history recorded before the swap No. Independent of scroll, and kept
#601 forward nav scroll is instant under scroll-behavior: smooth No. Forward scroll-to-top stays ours and is unchanged
#639 query params preserved on every nav path No
#610 sticky header flicker on iOS No. Fixed in CSS with position: fixed
#1015 route-keyed comment boundary swap No
#1114 full-load fallback is observable No
#936 / #906 CSS drop, hydrated-component corruption No
#1102 / #1252 script reactivation, permanent-element descendants No
#1046 page-scoped meta leak in the head merge No
#1398 refreshPage preserves scroll, records no entry 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.
  • Where the reservation goes. A min-height on the wrong element interacts
    with sticky and fixed positioning, which is exactly the neighbourhood of dogfood: mobile navbar flickers on forward nav (backdrop-blur sticky header) #610.
  • 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 implementation is separate tracked work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    researchResearch/design/decision record (no code); filter these to read design history

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions