Skip to content

fix(router): restore pages scroll traversal state - #1999

Merged
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/reload-scroll-restoration
Jun 13, 2026
Merged

fix(router): restore pages scroll traversal state#1999
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/reload-scroll-restoration

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Summary

  • Port the Next.js reload/back/forward scroll-restoration behavior for Pages Router e2e coverage.
  • Share Pages Router browser runtime state through the document window so production bundles with multiple next/router module instances agree on history keys, events, cancellation, and popstate state.
  • Move Pages Router hydration readiness into the stable router provider and resolve client navigations after layout scroll restoration plus passive effects, so page-level routeChangeComplete subscriptions can observe the event.
  • Retry scroll restoration at commit time and temporarily neutralize smooth scrolling when applying a saved scroll target.

Root Cause

The deploy-suite fixture exposed two coupled production-only problems. First, the entry chunk and a page chunk could evaluate separate next/router modules, leaving the popstate listener and window.next.router with separate history-key counters/current-key state. After reload plus back/forward traversal, the stale popstate-side key could snapshot the outgoing scroll into the wrong __next_scroll_<key> entry and overwrite the position that should be restored later.

Second, vinext emitted routeChangeComplete immediately after root.render() resolved, before the newly rendered page's passive effects had subscribed to router events. The upstream fixture renders routeChangeComplete only from a page-level useEffect listener, so the event ordering needed to match the browser-observable Next.js contract more closely.

References

Verification

  • vp test run tests/shims.test.ts
  • vp test run tests/entry-templates.test.ts -t "keeps the Pages Router client tree shape stable after hydration"
  • vp test run tests/pages-router.test.ts -t "client entry statically imports next/router|installs the vinext dev error overlay"
  • vp check
  • CI=1 PLAYWRIGHT_PROJECT=pages-scroll-restoration pnpm run test:e2e tests/e2e/pages-scroll-restoration/reload-scroll-backforward-restoration.spec.ts --reporter=line
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/reload-scroll-backforward-restoration/index.test.ts

@pkg-pr-new

pkg-pr-new Bot commented Jun 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1999
npm i https://pkg.pr.new/vinext@1999

commit: 7fbe24d

Pages Router HTML fallback could import the module URL from fetched __NEXT_DATA__ even when the generated page loader already existed. In Vite chunk graphs that can evaluate a duplicate page or router module, same-route param navigations remounted the page and lost listener state before routeChangeComplete.

Store the router wrapper component types on the shared browser runtime singleton, prefer registered page loaders on the HTML fallback path, and resolve navigation at the root commit boundary that Next.js awaits instead of waiting for passive effects. Non-DOM document mocks still resolve immediately so unit harnesses do not masquerade as real browser commits.

Validated with the upstream reload-scroll-backforward-restoration deploy suite and the local router-events and scroll-restoration regressions.
Move the pagesRouterReady bit into PagesRouterRuntimeState so duplicated
next/router module instances (client entry + page chunks) observe the same
readiness value. The initial value is still computed from
shouldDeferInitialPagesRouterReady() once per document.

Also tighten the scroll-restoration e2e fixture so routeChangeComplete
assertions cannot pass from stale page state, and add a regression test
that simulates two evaluated router instances on a deferred-ready page.
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review June 13, 2026 11:43
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Verified the href={/${id + 1}} concern against the upstream fixture at Next.js v16.2.6 pages/[id].js — the identical pattern is used there too (const { id = 0 } = context.query with href={/${id + 1}}). The string-concat behavior is intentional upstream parity. Since tests never click that link and it doesn't affect the observable contract being validated (scroll positions, routeChangeComplete ordering), no change needed.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: restore pages scroll traversal state

Reviewed the full diff, ran the targeted tests (tests/shims.test.ts runtime-state + HTML-fallback + popstate-scroll cases, tests/entry-templates.test.ts hydration-tree case) and vp check on router.ts — all green.

The core change is solid and well-reasoned:

  • Moving per-document router state onto a window-scoped runtime object (keyed by Symbol.for) correctly deduplicates state across independently-bundled next/router instances while keeping SSR module instances isolated. The comments explaining the typeof window === "undefined" carve-out and the real-browser-vs-jsdom distinction are excellent.
  • The deprecated-event-bridge install guard (deprecatedEventBridgeInstalled) plus reading routerRuntimeState.publicRouter at fire-time (rather than capturing a module-local Router) is the right way to avoid double-installs and stale targets when a second chunk re-evaluates.
  • Preferring the registered route loader on the HTML-fallback path to keep component identity stable is a good fix and is directly covered by the new test.
  • The DOM-text-based expectPageShowsRouteChangeComplete e2e assertion is more robust than the prior event-listener race and matches the browser-observable contract.

A few non-blocking observations below. None block merge; the behavior is intentional and tested, but worth a comment or follow-up note.


scrollToPagesRouterPosition(scroll);
if (isAtScrollPosition(scroll)) return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: when a saved scroll target can't be reached (e.g. the new page is shorter than the saved y after a back/forward), isAtScrollPosition never converges and this burns all SCROLL_RESTORE_MAX_FRAMES (~60 frames ≈ 1s at 60fps) before resolving. Because routeChangeComplete is gated behind this loop, that case delays the completion event by ~1s.

The scroll-restoration fixture has tall content so it converges quickly, but real apps with variable-height pages could hit this. Worth a short comment noting the cap is intentionally a hard upper bound on completion-event delay, or consider breaking early once window.scrollY stops changing across frames (content can't grow further).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Implemented an early-exit: the loop now tracks previousScrollY and breaks when window.scrollY stops changing across consecutive frames (meaning the content can't grow further). The 60-frame SCROLL_RESTORE_MAX_FRAMES cap remains as a safety net for edge cases like lazy-loaded content that takes multiple frames to settle. Pushed in b6546a2.


return router;
if (typeof window !== "undefined" && window.__VINEXT_PAGE_LOADERS__ !== undefined) {
return Router;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking behavioral note: this fallback returns the module-local Router singleton, which derives pathname/query from window.__NEXT_DATA__ and is not subscribed to vinext:navigate. A useRouter() consumer that hits this fallback (i.e. renders outside PagesRouterProvider) won't re-render on client navigation, unlike one that gets the reactive provider value.

In practice wrapWithRouterContext always provides the context so this should only trigger in genuine edge cases, but it's a silent behavior shift from the previous "throw with a docs link" path. A brief comment documenting that the fallback is intentionally non-reactive (and only meant for the split-chunk compat case) would help future readers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an inline comment documenting the fallback intent — that it returns the module-local, non-reactive Router singleton meant only for the split-chunk compat case where useRouter() is evaluated outside PagesRouterProvider. Pushed in b6546a2.

};
routerRuntimeState.components = components;
return components;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth confirming the intent: getPagesRouterRuntimeComponents() caches the first evaluating instance's PagesRouterProvider/PagesRouterCommitBoundary on the shared runtime state, so a later-evaluated chunk's wrapWithRouterContext will mount instance-A's provider/boundary. That's correct for React element identity (the goal), and instance-A's provider closes over shared routerRuntimeState, so it stays consistent — the new runtime-state-sharing test exercises exactly this. Just flagging that the cross-instance closure capture is load-by-load deterministic only because all the captured helpers read shared state; if any future field on those components becomes module-local-instance-specific, this caching would silently pick instance A. No change needed now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the caching is safe today because PagesRouterProvider only reads from shared routerRuntimeState. If future additions introduce module-local state on those components, the caching would need revisiting. No change needed now.

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Summary of what I reviewed and posted on PR #1999:

Verdict: Approve-with-notes (no blocking issues)

I reviewed the full diff, ran the targeted tests (runtime-state-sharing, HTML-fallback-loader, popstate-scroll in tests/shims.test.ts; hydration-tree case in tests/entry-templates.test.ts) and vp check on router.ts — all green.

The core change is solid:

  • Moving per-document router state onto a window-scoped object keyed by Symbol.for correctly dedupes state across split next/router chunks while keeping SSR instances isolated.
  • The deprecated-event-bridge install guard + reading publicRouter at fire-time avoids double-installs/stale targets.
  • Preferring the registered loader on the HTML-fallback path (component identity) is well-reasoned and tested.
  • The DOM-text e2e assertion is more robust than the prior event-listener race.

Three non-blocking observations posted as inline comments:

  1. Scroll-restore loop (line 219): unreachable scroll targets burn all 60 frames (~1s) before resolving, which delays routeChangeComplete. Suggested an early-break when scrollY stops changing, or a comment noting the cap is an intentional upper bound.
  2. useRouter fallback (line 2332): returns the non-reactive module-local singleton; a consumer hitting this path won't re-render on navigation. Silent shift from the prior throw — suggested documenting it as intentional compat-only.
  3. Component caching (line 466): confirmed the cross-instance closure capture is safe today because the captured helpers all read shared state; flagged it as a latent footgun if a future field becomes instance-specific.

No code changes were made (review-only mode).

github run

…ocument useRouter fallback

Addresses ask-bonk review feedback on PR cloudflare#1999:

- restorePagesRouterScrollPosition now breaks early when window.scrollY
  stops changing across frames, avoiding the full 60-frame budget when
  the saved scroll position exceeds the rendered page height.
- useRouter() fallback path now has an explanatory comment documenting
  the non-reactive singleton return is intentional for split-chunk compat.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: restore pages scroll traversal state

Reviewed the full diff and ran the targeted suites locally with vp:

  • vp test run tests/shims.test.ts -t "runtime state sharing|HTML fallback prefers the registered route loader|falls back to the singleton|popstate restores history-state scroll|Pages Router concurrent navigation" — all green (41 concurrent-nav + 4 new cases)
  • vp test run tests/entry-templates.test.ts -t "keeps the Pages Router client tree shape stable after hydration" — green
  • vp check packages/vinext/src/shims/router.ts — no format/lint/type errors

The core design is solid and matches Next.js closely:

  • scrollToPagesRouterPosition is a faithful port of Next.js's disableSmoothScrollDuringRouteTransition (v16.2.6 shared/lib/router/utils/disable-smooth-scroll.ts): the dataset.scrollBehavior === "smooth" gate, the getClientRects() forced reflow before scrollTo, and the restore of the prior style.scrollBehavior all line up exactly.
  • Moving per-document state onto a Symbol.for-keyed window object correctly dedupes router state across split next/router chunks while keeping SSR module instances isolated, and the install guards (deprecatedEventBridgeInstalled, components cache, reading publicRouter at fire-time) avoid double-installs/stale targets.
  • Preferring the registered route loader on the HTML-fallback path is a good change — it also sidesteps the import(/* @vite-ignore */ pageModuleUrl) branch that needs isValidModulePath validation, since the loader map is build-controlled. Well covered by the new test.

The prior /bigbonk review already covered the scroll-loop frame budget, the useRouter non-reactive fallback, and the component-cache cross-instance capture (all addressed). Two additional non-blocking observations below.

Comment thread packages/vinext/src/shims/router.ts Outdated
scrollToPagesRouterPosition(scroll);
if (isAtScrollPosition(scroll)) return;

if (window.scrollY === previousScrollY) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the early-break unreachability check only tracks window.scrollY (previousScrollY), but the target has both x and y and isAtScrollPosition checks both axes. If a restored page is still growing horizontally (e.g. async wide content) while scrollY has already settled at an unreachable value, the loop breaks on the first stalled scrollY frame even though x could still become reachable. The common case (vertical scroll) is handled correctly, so this is just an edge-case note — consider tracking both axes (scrollX + scrollY) for symmetry, or add a comment that the break is intentionally vertical-only since that's the dominant restoration axis.

});
});
async function expectPageShowsRouteChangeComplete(page: Page, expectedPath: string): Promise<void> {
await expect(page.locator("html")).toContainText(`routeChangeComplete:${expectedPath}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking robustness note: toContainText("routeChangeComplete:/1") is a substring match, so it would also pass against routeChangeComplete:/12. The fixture only ever produces single-digit ids (/0, /1, /2), so this is safe today, but if the fixture ever grows to two-digit ids this assertion could match the wrong page. An exact-match locator (e.g. page.locator("p", { hasText: ... }) with toHaveText) would be more robust against that.

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully to PR #1999.

Summary

Verdict: Approve-with-notes (no blocking issues)

I reviewed the full diff, verified Next.js parity against the v16.2.6 source, and ran the targeted suites locally with vp — all green:

  • tests/shims.test.ts (runtime-state-sharing, HTML-fallback-loader, useRouter-fallback, popstate-scroll, all 41 concurrent-nav cases)
  • tests/entry-templates.test.ts (hydration-tree shape)
  • vp check packages/vinext/src/shims/router.ts (clean)

The core change is solid:

  • scrollToPagesRouterPosition is a faithful port of Next.js's disableSmoothScrollDuringRouteTransition — the dataset.scrollBehavior gate, getClientRects() forced reflow, and style restore all match exactly.
  • Moving per-document state onto a Symbol.for-keyed window object correctly dedupes router state across split next/router chunks while keeping SSR instances isolated. The install guards prevent double-installs/stale targets.
  • Preferring the registered route loader on the HTML-fallback path is well-reasoned — it also sidesteps the @vite-ignore dynamic-import branch that needs path validation.

Two new non-blocking observations posted as inline comments:

  1. Scroll-restore early-break (router.ts:221): only tracks scrollY, not scrollX — an edge case where horizontal content is still settling could break early. Common (vertical) case is fine.
  2. e2e assertion (spec.ts:32): toContainText("routeChangeComplete:/1") is a substring match that would also match /12. Safe with single-digit fixture ids today, but fragile if ids grow.

The prior /bigbonk review's three observations (scroll-loop budget, non-reactive useRouter fallback, component-cache capture) were all addressed in commit b6546a2. No code changes made (review-only mode).

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

2 similar comments
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: restore pages scroll traversal state

Reviewed the full diff and ran the targeted suites locally with vp — all green:

  • vp test run tests/shims.test.ts -t "runtime state sharing|HTML fallback prefers the registered route loader|falls back to the singleton|popstate restores history-state scroll" (4 passed)
  • vp test run tests/entry-templates.test.ts -t "keeps the Pages Router client tree shape stable after hydration" (passed)
  • vp check packages/vinext/src/shims/router.ts (clean — no format/lint/type errors)

Verdict: Approve-with-notes — no blocking issues.

The core change is solid and the design is well-reasoned:

  • Moving per-document router state onto a Symbol.for-keyed window object correctly dedupes state across split next/router chunks while keeping SSR module instances isolated. The typeof window === "undefined" carve-out and the jsdom-vs-real-browser comments are clear.
  • The deprecatedEventBridgeInstalled guard plus reading routerRuntimeState.publicRouter at fire-time correctly avoids double-installs and stale targets when a second chunk re-evaluates.
  • Preferring the registered route loader on the HTML-fallback path keeps React component identity stable and sidesteps the @vite-ignore dynamic-import branch. Well covered by the new test.
  • scrollToPagesRouterPosition faithfully ports Next.js's smooth-scroll-disable behavior, and the early-break now correctly tracks both x and y, addressing the prior review note.
  • The e2e assertion uses getByText(..., { exact: true }), which fully resolves the earlier substring-match (/1 vs /12) concern.

The prior /bigbonk observations (scroll-loop budget, non-reactive useRouter fallback, component-cache cross-instance capture) were all addressed in b6546a2. A few additional non-blocking notes below.

return true;
}

function markPagesRouterHydrated(): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking behavioral note: this PR moves the __NEXT_HYDRATED / __VINEXT_HYDRATED_AT write out of the synchronous post-hydrateRoot() path (removed from pages-client-entry.ts and dev-server.ts) into this passive useEffect-driven marker. The markers are now set strictly later — after the first commit's passive effects flush rather than right after hydrateRoot() returns.

That's the intended fix (so page-level routeChangeComplete subscriptions are installed before readiness is observed), and waitForHydration / the Pages Router instrumentation-client timing assertion (instrumentation < hydration) still hold because the marker only moves later. Just flagging it because a wide set of Pages Router e2e specs gate on __VINEXT_HYDRATED_AT, so any future regression in the provider mount path would now also stall hydration detection. Worth a one-line note in the PR description that this is a deliberate ordering shift for the many specs that depend on the marker.

// `useRouter().isReady` still uses the serialized readiness snapshot to
// avoid server/client markup drift during hydration.
return (
isPagesRouterReady() && (typeof window === "undefined" || window.__NEXT_HYDRATED === true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: window.next.router.isReady now additionally gates on window.__NEXT_HYDRATED === true, which is only ever flipped by PagesRouterHydrationMarker inside PagesRouterProvider. If a Pages Router document somehow renders without the provider ever committing (e.g. an early hydration error that the commit boundary swallows on the initial tree), the singleton isReady would stay false indefinitely while the provider-backed useRouter().isReady could still report true via the serialized snapshot. In normal flows wrapWithRouterContext always mounts the provider so this can't happen, but it's a new coupling between two readiness signals that previously were independent. No change needed; documenting the invariant (singleton readiness depends on the provider committing at least once) would help future debugging.

return () => {
router.events.off("routeChangeComplete", handler);
};
}, [router]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (fixture-only): the effect deps are [router], and with this PR the provider's router object identity changes on every vinext:navigate snapshot. So on each navigation the effect tears down and re-subscribes the routeChangeComplete handler. It works here because subscribe/unsubscribe are symmetric and the reset-on-id-change runs in the same effect, but it means the handler is briefly absent across the re-run. Since routeChangeComplete for the incoming navigation fires after the new tree commits (and thus after this effect re-runs), the ordering happens to be safe. If this fixture pattern is ever copied as a reference, a router.events-only dependency (or an empty dep array reading idRef) would be less subtle.

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully to PR #1999.

Summary

Verdict: Approve-with-notes — no blocking issues.

I reviewed the full diff, traced the cross-module-instance state-sharing design, and ran the targeted suites locally with vp — all green:

  • tests/shims.test.ts (runtime-state-sharing, HTML-fallback-loader, useRouter-fallback, popstate-scroll — 4 passed)
  • tests/entry-templates.test.ts (hydration-tree shape — passed)
  • vp check packages/vinext/src/shims/router.ts (clean)

The core change is solid:

  • Symbol.for-keyed window runtime state correctly dedupes router state across split next/router chunks while keeping SSR instances isolated.
  • The deprecated-event-bridge install guard + reading publicRouter at fire-time avoids double-installs/stale targets.
  • Preferring the registered route loader on the HTML-fallback path keeps component identity stable and sidesteps the @vite-ignore import branch.
  • The early-break scroll loop now tracks both axes (prior reviewer's concern addressed), and the e2e assertion uses { exact: true } (resolving the substring-match concern).
  • All three prior /bigbonk observations were addressed in commit b6546a2.

Three new non-blocking observations posted as inline comments:

  1. Hydration-marker timing shift (router.ts:1205): __NEXT_HYDRATED/__VINEXT_HYDRATED_AT now set in a passive effect instead of synchronously after hydrateRoot(). Functionally safe, but many Pages Router e2e specs gate on the marker — worth documenting as a deliberate ordering shift.
  2. New coupling in isReady (router.ts:2904): the singleton isReady now also gates on __NEXT_HYDRATED, which only flips when the provider commits. Documenting that invariant would aid future debugging.
  3. Fixture effect deps ([id].js:28): [router] re-subscribes the handler on every navigation snapshot — works due to symmetric subscribe/unsubscribe and commit ordering, but subtle if copied as a reference pattern.

No code changes were made (review-only mode).

github run

@james-elicx
james-elicx merged commit 4baf85a into cloudflare:main Jun 13, 2026
44 checks passed
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.

2 participants