Skip to content

fix(pages-router): restore scroll across reload history traversal#1905

Open
NathanDrake2406 wants to merge 19 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/reload-scroll-backforward-restoration
Open

fix(pages-router): restore scroll across reload history traversal#1905
NathanDrake2406 wants to merge 19 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/reload-scroll-backforward-restoration

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Field Detail
Goal Match Next.js Pages Router scroll restoration after reload plus back and forward traversal.
Core change Enable experimental.scrollRestoration, persist per-entry scroll snapshots, and install the Pages Router runtime before window.next.router is exposed.
Main boundary Pages Router client navigation and browser history state.
Primary files packages/vinext/src/shims/router.ts, packages/vinext/src/config/next-config.ts, packages/vinext/src/index.ts, tests/e2e/pages-scroll-restoration/reload-scroll-backforward-restoration.spec.ts.
Expected impact Pages apps that opt into scroll restoration now keep exact scroll positions across reload, back, and forward navigation.

Why

When experimental.scrollRestoration is enabled, the Pages Router owns browser scroll restoration. That requires history.scrollRestoration = "manual", a stable key for each history entry, durable scroll snapshots that survive reload, and routeChangeComplete only after the new page has committed.

Area Principle / invariant What this PR changes
Config The Next.js experimental flag must become a client-side define. Resolves experimental.scrollRestoration and defines process.env.__NEXT_SCROLL_RESTORATION.
History state Scroll memory belongs to the history entry key, not only the current JS heap. Writes and reads Next-style sessionStorage entries named __next_scroll_<key>.
Runtime ordering Test harnesses and userland must not observe window.next.router.isReady before the popstate runtime exists. Installs the Pages Router runtime while next/router evaluates, before window.next.router is exposed.
Navigation lifecycle routeChangeComplete should represent a committed route. Routes navigation renders through a stable commit boundary before emitting completion.

What Changed

Scenario Before After
Initial load with experimental.scrollRestoration Browser stayed on history.scrollRestoration = "auto". Browser switches to manual restoration when sessionStorage is usable.
Reload then back or forward The target entry could lose its scroll position and the popstate handler could race router readiness. The current entry is snapshotted before traversal and the target key restores from sessionStorage.
Pages navigation render The initial hydration tree did not include the same commit boundary used by later route renders. Initial and navigated Pages Router trees share the same wrapper shape.
Maintainer review path
  1. packages/vinext/src/shims/router.ts: runtime ordering, history key tracking, sessionStorage scroll snapshots, and the commit boundary around client renders.
  2. packages/vinext/src/config/next-config.ts and packages/vinext/src/index.ts: config resolution and client define parity with Next.js.
  3. tests/e2e/pages-scroll-restoration/reload-scroll-backforward-restoration.spec.ts plus tests/fixtures/pages-scroll-restoration: the ported upstream regression test and fixture.
  4. tests/shims.test.ts and tests/next-config.test.ts: focused lower-boundary coverage for runtime install ordering and config parsing.
Validation
  • vp check
  • vp test run tests/next-config.test.ts -t scrollRestoration
  • vp test run tests/shims.test.ts -t "Pages Router concurrent navigation"
  • vp test run tests/pages-router.test.ts -t "client entry statically imports next/router"
  • CI=1 PLAYWRIGHT_PROJECT=pages-scroll-restoration vp run test:e2e tests/e2e/pages-scroll-restoration/reload-scroll-backforward-restoration.spec.ts
  • 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

The pre-commit hook also passed entry template snapshots, vp check --fix, full check, staged tests, and knip.

Risk / Compatibility
  • Public API: no new public API.
  • Config: newly honors an existing Next.js experimental config flag.
  • Runtime: only opts into manual restoration when the flag is true and sessionStorage is usable, matching the Next.js guard.
  • Existing apps: apps without experimental.scrollRestoration keep browser-managed scroll restoration.
  • Intentional divergence: none intended. The port uses Playwright against a vinext production fixture instead of Next.js next-webdriver, but preserves the same route structure, config flag, navigation sequence, readiness wait, routeChangeComplete wait, and exact scroll assertions.
Non-goals
  • Does not change App Router scroll behavior.
  • Does not add broader hash-scroll or shallow-routing coverage beyond the upstream bug candidate.

References

Reference Why it matters
Next.js upstream regression test Defines the reload plus back and forward scroll restoration contract ported here.
Next.js fixture config Shows the behavior is gated by experimental.scrollRestoration.
Next.js fixture page Provides the route structure and routeChangeComplete observable used by the test.
Next.js define-env Maps the config flag to process.env.__NEXT_SCROLL_RESTORATION.
Next.js config default Confirms the default is false unless the experimental flag is enabled.
Next.js router scroll restoration Guards manual scroll restoration on the env flag, history support, and sessionStorage.
Next.js sessionStorage snapshots Shows scroll snapshots are keyed as __next_scroll_<key>.
Next.js route commit ordering Uses a layout-effect callback so render completion represents a committed route.
Next.js router events docs Documents routeChangeComplete as the Pages Router completion signal observed by the fixture.

Pages Router pages with experimental.scrollRestoration currently expose window.next.router before the popstate runtime is installed and only keep Vinext scroll coordinates in the in-memory history state. After a reload, a back or forward traversal can run before the runtime is ready and has no durable scroll snapshot for the target history entry.

The violated invariant is that the Pages Router owns browser scroll restoration whenever the Next.js scroll restoration flag is enabled, with one stable history key per entry and routeChangeComplete emitted after the new page commits.

Resolve the Next.js config flag into process.env.__NEXT_SCROLL_RESTORATION, install the Pages Router runtime during next/router evaluation, persist Next-style sessionStorage scroll snapshots per history key, and render navigations through a stable commit boundary before emitting routeChangeComplete.

Regression coverage ports Next.js reload-scroll-backforward-restoration and verifies manual restoration plus exact scroll positions across reload, back, and forward traversal.
@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 86d9231

Move hash scrolling out of renderPagesRouterElement's commit callback
and into the post-routeChangeComplete path, matching Next.js ordering:
x/y restoration happens during render commit, hash scrolling happens
after the completion event.

Also fix E2E test expectRouteChangeComplete to use one-shot event
listeners instead of checking for stale text, and add listener cleanup
to the fixture to prevent accumulation.
…2E helper

expectRouteChangeComplete() now throws if window.next.router.events is
absent instead of silently resolving. Remove the redundant
expectRouteChangeComplete call after pushWithPagesRouter in the third
test — the awaited router.push already resolves after routeChangeComplete,
so registering a listener after it would deadlock.
…te and cancel pending render on supersede

- Pass {x:0, y:0} through commit boundary for all scrollable
  navigations, not just hash-less ones (hash scroll deferred to after
  routeChangeComplete as before).
- Cancel pending render-commit Promise when a newer navigation starts,
  preventing hang when the first root.render() commit is dropped by
  React re-reconciliation.
- Add regression tests for both edge cases.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 11, 2026 15:47
@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.

Reviewed the scroll-restoration changes. The history-key tracking, sessionStorage snapshots, and runtime-install ordering all look sound and closely mirror the upstream Next.js behavior, with good test coverage including the ported regression test.

One correctness concern worth fixing before merge: the new commit boundary uses a raw useLayoutEffect, but wrapWithRouterContext is shared with the server-side SSR render path, so this will emit React's "useLayoutEffect does nothing on the server" warning on every Pages Router SSR render. The codebase already established the SSR-safe pattern for exactly this situation (shims/image.tsx). A couple of smaller observations inline as well.

}): ReactElement {
useLayoutEffect(() => {
onCommit();
}, [onCommit]);

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.

wrapWithRouterContext is not client-only — it is invoked on the server during Pages Router SSR (e.g. server/pages-page-handler.ts:511, entries/pages-server-entry.ts:378, and several call sites in server/dev-server.ts). Since PagesRouterCommitBoundaryHelper always calls useLayoutEffect, React will log

Warning: useLayoutEffect does nothing on the server, because its effect cannot be observed during rendering.

for every Pages Router SSR render. On the server onCommit is the noopCommit default anyway, so the layout effect is pure overhead there.

This repo already established the SSR-safe pattern for exactly this case in shims/image.tsx:125:

// A version of useLayoutEffect that doesn't warn during SSR.
const useNonWarningLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

Next.js itself only runs the layout-effect callback on the client (packages/next/src/client/index.tsx), not in the shared render path. Suggest adopting the same guard (useEffect is already imported):

Suggested change
}, [onCommit]);
useNonWarningLayoutEffect(() => {
onCommit();
}, [onCommit]);
return createElement(Fragment, null, children);
}
const useNonWarningLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

(Define useNonWarningLayoutEffect at module scope rather than inline; placement is illustrative.)

onCommit: () => void;
onError: (error: Error) => void;
}> {
componentDidCatch(error: Error) {

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.

PagesRouterCommitBoundary implements only componentDidCatch and never updates state via getDerivedStateFromError, so on a caught error React unmounts the whole subtree. For the navigation path this is intended (the rejection drives the hard-nav fallback in runNavigateClient). But this same boundary now also wraps the initial hydration tree (pages-client-entry.ts:185 / dev-server.ts:1387) with the default onError = noopCommit, so a hydration-time render error is now caught and routed to a no-op instead of surfacing as before. React still console.errors the caught error, so it is not fully silent, but it is a behavior change vs. the pre-PR tree that had no boundary. Worth confirming this is intentional — a brief comment on wrapWithRouterContext noting that the default-noop boundary also governs hydration would help future readers.

if (doScroll) {
if (hash) scrollToHashTarget(hash);
else window.scrollTo(0, 0);
}

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.

Minor ordering change for the shallow path: previously the post-routeChangeComplete block scrolled (hash or (0,0)) for both shallow and non-shallow when doScroll was set. Now shallow navigations scroll in this else branch before routeChangeComplete, while the non-shallow no-hash case relies on the (0,0) reset inside the render-commit callback. The reordering for shallow is observable to routeChangeComplete listeners (scroll now applied before the event rather than after). Probably fine since shallow routing rarely changes scroll, but it is an untested ordering shift — consider adding a shallow-routing assertion or a note that the divergence is intentional.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully. Summary of what I found in PR #1905:

Review posted

Overall: The core implementation (history-key tracking, sessionStorage scroll snapshots, runtime-install ordering, commit-boundary lifecycle) is sound and closely mirrors upstream Next.js, with strong test coverage including a ported regression test.

Main issue (correctness):

  • PagesRouterCommitBoundaryHelper uses a raw useLayoutEffect, but wrapWithRouterContext is shared with the server-side SSR render path (pages-page-handler.ts:511, pages-server-entry.ts:378, dev-server.ts). This will emit React's "useLayoutEffect does nothing on the server" warning on every Pages Router SSR render. The repo already has the SSR-safe pattern in shims/image.tsx:125 (typeof window === "undefined" ? useEffect : useLayoutEffect), and Next.js itself runs the layout-effect callback client-only. Posted a suggestion to adopt the guarded variant.

Smaller observations:

  • The error boundary (componentDidCatch with no getDerivedStateFromError) now also wraps initial hydration with a noopCommit error handler — a behavior change vs. the pre-PR tree. Likely fine but worth a confirming comment.
  • The shallow-navigation scroll now runs before routeChangeComplete (was after pre-PR) — an untested ordering shift worth a note or assertion.

No blocking concerns beyond the SSR useLayoutEffect warning, which is the one I'd recommend fixing before merge.

github run

…allow scroll ordering

Address bonk review on cloudflare#1905:
- swap the commit boundary's raw useLayoutEffect for the SSR-safe
  useNonWarningLayoutEffect pattern from shims/image.tsx so Pages Router
  SSR renders no longer log React's useLayoutEffect warning
- document that the default-noop commit boundary intentionally also wraps
  SSR and initial hydration
- document the intentional shallow-scroll-before-routeChangeComplete
  ordering and add a unit test pinning it
@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.

Reviewed the scroll-restoration changes. The opt-in path (history-key tracking, sessionStorage snapshots, runtime-install ordering, commit boundary) is sound and mirrors upstream Next.js closely, with good coverage from the ported regression test. The prior useLayoutEffect-on-SSR concern has already been addressed via useNonWarningLayoutEffect.

One concern I'd want resolved before merge: this PR appears to silently regress back/forward scroll restoration for Pages Router apps that do not opt into experimental.scrollRestoration. Details inline.

Comment thread packages/vinext/src/shims/router.ts Outdated
void (async () => {
const scrollTarget = manualScrollRestoration
? (forcedScroll ?? readScrollPosition(state) ?? { x: 0, y: 0 })
: null;

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.

Regression for apps without experimental.scrollRestoration.

On main, the popstate handler unconditionally called restoreScrollPosition(e.state) after routeChangeComplete, restoring the __vinext_scrollX/Y saved in history state by saveScrollPosition() on every back/forward — for all Pages Router apps, regardless of the flag.

Now scrollTarget is null whenever manualScrollRestoration is false, so renderPagesRouterElement does no scrolling, and there is no other scroll-restore on the popstate path. For an app without the experimental flag, back/forward scroll position is no longer restored.

This matters because vinext's Pages Router popstate is a soft navigation (it re-fetches /_next/data and calls root.render()). The browser's native "auto" scroll restoration cannot restore the correct position for content that is re-rendered after the popstate fires — which is exactly why the old code compensated with restoreScrollPosition. Dropping it leaves these apps scrolled to the wrong position on back/forward.

Note that saveScrollPosition() still writes __vinext_scrollX/Y into history state on push (line 537), and readScrollPosition(state) can already read that shape — so the fix is likely just to also honor it when the flag is off. Suggestion:

Suggested change
: null;
// When manual scroll restoration is enabled we drive the position from the
// sessionStorage snapshot keyed by history key. When it is disabled we
// still restore the per-entry scroll saved in history state on push, since
// a soft popstate re-renders content the browser's native restoration
// can't position correctly.
const scrollTarget = manualScrollRestoration
? (forcedScroll ?? readScrollPosition(state) ?? { x: 0, y: 0 })
: readScrollPosition(state);

If this drop is intentional (e.g. you verified Next.js itself does not restore here for non-flag apps), please add a test pinning the new behavior and a comment explaining the divergence — the new fixture only covers the flag-enabled case, so nothing currently guards this.

if (manualScrollRestoration) {
const currentKey = _currentHistoryKey;
if (currentKey !== undefined && currentKey !== targetKey) {
saveScrollPositionToSessionStorage(currentKey, getWindowScrollPosition());

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.

Minor: this saves getWindowScrollPosition() under currentKey at popstate time. This is correct only because history.scrollRestoration = "manual" is set when manualScrollRestoration is true, so the browser hasn't already moved window.scrollX/Y to the target entry's position by the time popstate fires. Worth a one-line comment noting the dependency on manual mode, since it would silently capture the wrong position if the manual guard ever drifted from this block's guard.

if (hash) scrollToHashTarget(hash);
else window.scrollTo(0, 0);
}
}

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.

The shallow path now scrolls before routeChangeComplete, whereas on main the scroll ran after the event for both shallow and non-shallow. The new tests/shims.test.ts test pins ["scrollTo:0,0", "routeChangeComplete"], so the ordering change is at least covered — good. Just flagging that this is an observable behavior change for any shallow-routing routeChangeComplete listener that reads scroll position; if that's intentional (matching the non-shallow commit-callback ordering, as the comment says), no action needed.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Review posted

Overall: The opt-in scroll-restoration path is well implemented — history-key tracking, sessionStorage snapshots, runtime-install ordering, and the commit boundary closely mirror upstream Next.js, with solid coverage from the ported regression test. The earlier useLayoutEffect-on-SSR concern raised in prior review has already been fixed via useNonWarningLayoutEffect.

Main concern (potential regression):

  • packages/vinext/src/shims/router.ts:2378 — The popstate handler now restores scroll only when manualScrollRestoration is true. On main, it unconditionally called restoreScrollPosition(e.state) for all Pages Router apps. Since vinext's popstate is a soft re-fetch + root.render(), the browser's native "auto" restoration can't position re-rendered content correctly — which is why the old code compensated. Apps that don't opt into experimental.scrollRestoration would lose back/forward scroll restoration. Posted a suggestion to also honor the __vinext_scrollX/Y history-state shape (still written on push at line 537) when the flag is off, plus a note to add a test/comment if the drop is intentional. The new fixture only covers the flag-enabled case, so nothing currently guards this.

Smaller notes:

  • router.ts:2324 — capturing current window scroll on popstate is correct only because manual mode disables native restoration; suggested a one-line comment documenting that dependency.
  • router.ts:2068 — the shallow path now scrolls before routeChangeComplete (was after on main). The new shims.test.ts test pins the ordering, so it's covered; flagged as an observable behavior change in case it's unintentional.

No blocking issues beyond confirming/fixing the non-flag back/forward scroll behavior.

github run

…lRestoration flag is off

Without experimental.scrollRestoration, popstate must still honour the
__vinext_scrollX/Y saved into history state on push, since a soft popstate
re-renders content the browser's native restoration can't position. Also
document why the popstate-time sessionStorage snapshot depends on
history.scrollRestoration = "manual".
@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.

Reviewed the Pages Router scroll-restoration changes. The implementation closely mirrors upstream Next.js: history-key tracking, sessionStorage snapshots keyed __next_scroll_<key>, the history.scrollRestoration = "manual" opt-in guard, runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle so routeChangeComplete represents a committed route. Test coverage is strong (ported regression test + focused unit tests), and the prior-round concerns (SSR useLayoutEffect warning, non-flag back/forward regression) have both been addressed.

No blocking issues. One latent correctness concern (a render-commit canceller race) and two smaller observations below.

if (typeof document === "undefined") {
_cancelPendingRenderCommit = null;
resolve();
}

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.

Latent race: the render-commit callbacks unconditionally null the module-level _cancelPendingRenderCommit, even after a newer render has replaced it.

Flow that triggers it:

  1. Navigation A calls renderPagesRouterElement → installs cancellerA (line 141).
  2. Navigation B starts; navigateClient (line 1664) + cancelPreviousRenderCommit reject A's promise and set _cancelPendingRenderCommit = null, then B's renderPagesRouterElement installs cancellerB.
  3. A's React tree commits late (concurrent React can still run the old boundary's layout effect before unmount). A's onCommit runs _cancelPendingRenderCommit = null (line 156) — nulling cancellerB.
  4. Navigation C's cancelPreviousRenderCommit() is now a no-op, so B's render-commit promise can hang if C supersedes B before B commits.

The onError callback (line 165) and the document === undefined branch (line 171) have the same unconditional-null. Capturing the canceller identity and only clearing when it's still current closes the window:

Suggested change
}
return new Promise<void>((resolve, reject) => {
const cancel = () => {
if (_cancelPendingRenderCommit === cancel) _cancelPendingRenderCommit = null;
reject(new NavigationCancelledError("superseded"));
};
_cancelPendingRenderCommit = cancel;
const clearIfCurrent = () => {
if (_cancelPendingRenderCommit === cancel) _cancelPendingRenderCommit = null;
};
const scrollHandler = () => {
if (scroll) {
window.scrollTo(scroll.x, scroll.y);
}
};
root.render(
wrapWithRouterContext(
element,
() => {
clearIfCurrent();
try {
scrollHandler();
resolve();
} catch (err) {
reject(err);
}
},
(error) => {
clearIfCurrent();
reject(error);
},
),
);
if (typeof document === "undefined") {
clearIfCurrent();
resolve();
}
});

Low probability in practice (the superseded tree usually unmounts before its layout effect fires), so non-blocking — but worth hardening since the symptom (a hung navigation promise) is exactly what the _cancelPendingRenderCommit machinery exists to prevent.

// Match Next.js's Router constructor: stamp the initial history entry and
// attach the popstate listener while next/router itself is evaluating,
// before window.next.router is exposed to userland.
installPagesRouterRuntime();

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.

Moving installPagesRouterRuntime() to the next/router module top-level (instead of the explicit call in the Pages client entries) means the popstate handler + replaceState history stamp now run whenever next/router is evaluated as a value import — including in an App Router app where userland imports next/router directly. next/compat/router is type-only so it's unaffected, and Next.js's own Router constructor runs at module eval too, so this is defensible parity. Just flagging it as an observable behavior change vs. main (where install was Pages-entry-gated) in case an App Router fixture imports next/router for compat. Worth a one-line note in the comment that this install is intentionally unconditional at module eval.

* nothing on the server" warning on every render. Same pattern as
* `shims/image.tsx`; Next.js only runs the commit callback on the client.
*/
const useNonWarningLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

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.

Good catch addressing the SSR useLayoutEffect warning here — matches the established shims/image.tsx pattern. Note that PagesRouterCommitBoundaryHelper's effect depends on [onCommit], and onCommit is a fresh closure on every renderPagesRouterElement call, so the layout effect re-fires on each commit. That's correct for the single-render-per-navigation case and harmless for hydration (default noopCommit), so no action needed — just confirming the dependency array is intentional rather than a stale-closure guard.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Review posted

Overall: The opt-in scroll-restoration path is well implemented and closely mirrors upstream Next.js — history-key tracking, sessionStorage snapshots keyed __next_scroll_<key>, the history.scrollRestoration = "manual" guard, runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle. Both prior-round concerns (SSR useLayoutEffect warning, non-flag back/forward regression) have already been addressed in this version, and test coverage is strong.

Main finding (non-blocking, latent correctness):

  • router.ts:140-173 — The render-commit callbacks (onCommit, onError, and the document === undefined branch) all unconditionally set the module-level _cancelPendingRenderCommit = null. If a superseded render commits late (after a newer render installed its own canceller), the stale callback nulls the newer canceller, so a subsequent navigation's cancelPreviousRenderCommit() becomes a no-op and the render-commit promise can hang — the exact failure this machinery exists to prevent. Posted a suggestion to capture the canceller identity and only clear when current. Low probability (superseded trees usually unmount before their layout effect fires), hence non-blocking.

Smaller observations:

  • router.ts:2724 — Moving installPagesRouterRuntime() to next/router module top-level means the popstate handler + history stamp now install on any value import of next/router, including in App Router apps that import it for compat. Defensible parity with Next.js's own constructor-at-eval behavior; flagged as an observable change worth a one-line note.
  • router.ts:95 — Confirmed the [onCommit] dependency on the commit-boundary layout effect is intentional (fresh closure per render); no action needed.

No blocking issues found.

github run

@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.

Reviewed the Pages Router scroll-restoration changes. The implementation is sound and closely mirrors upstream Next.js: the experimental.scrollRestoration config -> client define plumbing, history-key tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard, runtime-install ordering before window.next.router is exposed, the commit-boundary lifecycle, and the SSR-safe useNonWarningLayoutEffect. The three substantive concerns from prior review rounds (SSR useLayoutEffect warning, non-flag back/forward regression, render-commit canceller race) all appear resolved in this version, and the unit + ported e2e coverage is strong. I ran the new shims.test.ts and next-config.test.ts cases locally and they pass.

One minor non-blocking observation below; no blocking issues.

Comment thread packages/vinext/src/shims/router.ts Outdated
}

if (targetKey !== undefined) {
_currentHistoryKey = targetKey;

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.

Minor (non-blocking): _currentHistoryKey is updated to targetKey here, before the beforePopState callback runs below (line 2348). If beforePopState returns false and cancels the navigation, the app stays on the previous entry but _currentHistoryKey now points at the target key. This is inconsistent with how _lastPathnameAndSearch is deliberately deferred until after beforePopState confirms the navigation proceeds (line 2357-2360, with the explanatory comment). A later saveScrollPosition() / popstate would then key off the wrong entry.

The scroll snapshot/read above it is fine to compute eagerly (it's local), but the commit of _currentHistoryKey ideally moves below the beforePopState early-return to match the _lastPathnameAndSearch handling. Low impact since beforePopState cancellation is rare, and the new fixture doesn't exercise it.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

Overall: This is a well-implemented opt-in scroll-restoration path that closely mirrors upstream Next.js v16.2.6. The config→client-define plumbing, history-key tracking, __next_scroll_<key> sessionStorage snapshots, history.scrollRestoration = "manual" guard, runtime-install ordering before window.next.router is exposed, commit-boundary lifecycle, and the SSR-safe useNonWarningLayoutEffect all look correct. I verified the new unit tests (shims.test.ts, next-config.test.ts) pass locally.

Prior-round concerns — all resolved in this version:

  • SSR useLayoutEffect warning → fixed via useNonWarningLayoutEffect
  • Non-flag back/forward regression → fixed by honoring __vinext_scrollX/Y from history state when the flag is off (covered by a new unit test)
  • Render-commit canceller race → fixed via clearIfCurrent identity check

One new minor observation (non-blocking):

  • router.ts:2344_currentHistoryKey is committed before the beforePopState early-return, unlike _lastPathnameAndSearch which is deliberately deferred until after beforePopState confirms the nav proceeds. If beforePopState cancels, the key drifts from the displayed entry. Low impact (cancellation is rare; not exercised by the fixture), so flagged as a polish item rather than a blocker.

No blocking issues found.

github run

…lows navigation

When beforePopState cancels a popstate the app stays on the previous
history entry, but _currentHistoryKey had already been updated to the
target key, skewing subsequent scroll-position bookkeeping. Move the
key commit below the beforePopState early-return, matching the
deliberate deferral of _lastPathnameAndSearch.
@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.

Reviewed the Pages Router scroll-restoration changes end to end. The implementation is sound and mirrors upstream Next.js v16.2.6 closely: experimental.scrollRestorationprocess.env.__NEXT_SCROLL_RESTORATION client define, history-key tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (only flipped when the flag is on, history supports it, and sessionStorage is usable), runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle so routeChangeComplete represents a committed route.

I verified the substantive concerns from the four prior review rounds are all resolved in this version:

  • SSR useLayoutEffect warning → useNonWarningLayoutEffect (router.ts:95), matching shims/image.tsx.
  • Non-flag back/forward regression → popstate still honors __vinext_scrollX/Y from history state when the flag is off (router.ts:2394-2396), covered by a new unit test.
  • Render-commit canceller race → clearIfCurrent identity check (router.ts:150-152, 164, 173).
  • _currentHistoryKey-before-beforePopState → the commit at router.ts:2359-2361 is now correctly placed after the if (!shouldContinue) return; early-return (router.ts:2350), consistent with _lastPathnameAndSearch.

Ran vp check on the touched files plus the targeted unit suites (next-config.test.ts -t scrollRestoration, shims.test.ts -t "Pages Router concurrent navigation") locally — all green. No blocking issues. Two minor, non-blocking observations below.

// Reading window scroll here is only correct because manualScrollRestoration
// implies history.scrollRestoration = "manual", so the browser hasn't yet
// moved the viewport to the target entry's position when popstate fires.
saveScrollPositionToSessionStorage(currentKey, getWindowScrollPosition());

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.

Minor (non-blocking): the outgoing-entry sessionStorage snapshot is written here before the beforePopState early-return at line 2350. If beforePopState cancels the traversal, the app stays on the current entry but its scroll has already been re-snapshotted under currentKey. That's harmless (it's the entry the app remains on, and the value is the live scroll), but it's a subtle asymmetry with the _currentHistoryKey commit which you deliberately deferred until after the cancel check. Worth a one-line note that the eager snapshot is intentional since currentKey is the entry we stay on either way.

/>
<p>{ready ? "routeChangeComplete" : "loading"}</p>
<Link
href={`/${id + 1}`}

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.

id arrives from context.query as a string, so `/${id + 1}` does string concatenation (/0/01, not /1). The spec navigates via pushWithPagesRouter(page, "/1") rather than clicking this Link, so the assertions aren't affected, and the upstream fixture has the same shape — but if anyone later switches the test to click #link the href won't match the expected next route. Non-blocking; flagging so it isn't mistaken for a working increment.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Review posted

Overall: The opt-in Pages Router scroll-restoration path is well implemented and closely mirrors upstream Next.js v16.2.6 — the config→client-define plumbing, history-key tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard, runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle. I ran vp check on the touched files and the targeted unit suites locally — all green.

Prior-round concerns — verified resolved in this version:

  • SSR useLayoutEffect warning → useNonWarningLayoutEffect
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y from history state when the flag is off (with a new unit test)
  • Render-commit canceller race → clearIfCurrent identity check
  • _currentHistoryKey-before-beforePopState ordering → now correctly placed after the cancel early-return

Two minor, non-blocking observations:

  1. router.ts:2335 — the outgoing-entry sessionStorage snapshot is written before the beforePopState early-return (harmless, but an asymmetry with the deliberately-deferred _currentHistoryKey commit; worth a comment).
  2. tests/fixtures/.../[id].jsx:34`/${id + 1}` does string concatenation (/0/01) since id is a query string. The spec navigates explicitly rather than clicking #link, so assertions are unaffected, but flagging so it isn't mistaken for a working increment.

No blocking issues found.

github run

…nk increment

- Document that the outgoing-entry scroll snapshot is deliberately written
  before the beforePopState gate: on cancellation the app stays on
  currentKey, so the saved value remains that entry's live scroll.
- Coerce id to a number in the pages-scroll-restoration fixture Link so
  the href increments numerically instead of string-concatenating.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 12, 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.

Reviewed the Pages Router scroll-restoration changes independently. The implementation is sound and closely mirrors upstream Next.js v16.2.6: the experimental.scrollRestoration -> process.env.__NEXT_SCROLL_RESTORATION client define, history-key tracking via _currentHistoryKey, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (flag + history support + usable sessionStorage), the runtime-install-before-window.next.router ordering, the commit-boundary lifecycle so routeChangeComplete represents a committed route, and the SSR-safe useNonWarningLayoutEffect.

I verified the substantive concerns from the prior six review rounds are all resolved in this version:

  • SSR useLayoutEffect warning -> useNonWarningLayoutEffect (router.ts:95)
  • Non-flag back/forward regression -> popstate still honors __vinext_scrollX/Y from history state when the flag is off (router.ts:2397-2399), with a new unit test
  • Render-commit canceller race -> clearIfCurrent identity check (router.ts:150-152, 164, 173)
  • _currentHistoryKey-before-beforePopState -> commit now correctly after the cancel early-return (router.ts:2362-2364)

No blocking issues. Two genuinely-unaddressed minor observations below, both narrow edge cases outside the ported fixture's scope.

}
_lastPathnameAndSearch = browserUrl;

if (isHashOnly) {

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 edge case: when manualScrollRestoration is enabled, a hash-only back/forward (isHashOnly) returns here after only scrollToHashTarget(...). The per-entry __next_scroll_<targetKey> snapshot computed above as forcedScroll (line 2342) is discarded, so the target entry's saved x/y is never restored — only the hash anchor is honored. With manual restoration, the browser's native "auto" restoration is disabled, so a hash-only entry that was scrolled to a non-anchor position won't restore. The PR explicitly lists broader hash-scroll coverage as a non-goal, so flagging rather than blocking — but the fixture doesn't exercise hash-only popstate, so nothing currently guards this path.

// attach the popstate listener while next/router itself is evaluating,
// before window.next.router is exposed to userland. This install is
// intentionally unconditional at module eval — any value import of
// next/router (even from an App Router app) triggers it, mirroring

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: combining this unconditional installPagesRouterRuntime() at module eval with the now-global process.env.__NEXT_SCROLL_RESTORATION define means an App Router app that (a) sets experimental.scrollRestoration: true and (b) imports next/router as a value for compat would flip history.scrollRestoration = "manual" via stampInitialHistoryState -> installManualScrollRestoration, even though App Router owns its own scroll behavior. Prior review flagged the unconditional install generally; this is the specific interaction with the new flag. Narrow (requires both conditions), but worth a one-line note that the manual-restoration opt-in is intended to be Pages-Router-only, or gating installManualScrollRestoration behind a router-mode check if App Router parity matters.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

Independently reviewed PR #1905 (Pages Router scroll restoration). The implementation is sound and closely mirrors upstream Next.js v16.2.6.

Verified resolved (from prior 6 review rounds):

  • SSR useLayoutEffect warning → useNonWarningLayoutEffect
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y when flag is off
  • Render-commit canceller race → clearIfCurrent identity check
  • _currentHistoryKey-before-beforePopState ordering → now after the cancel early-return

Two new minor, non-blocking observations posted inline:

  1. router.ts:2367 — Hash-only back/forward under manual restoration discards the computed forcedScroll snapshot; only the hash anchor is honored, not the saved x/y. Narrow edge case outside the fixture's scope (PR lists broader hash-scroll as a non-goal).
  2. router.ts:2738 — The unconditional module-eval install plus the now-global __NEXT_SCROLL_RESTORATION define means an App Router app that sets the flag and imports next/router for compat would flip history.scrollRestoration = "manual", even though App Router owns its own scroll handling. Requires both conditions, so narrow.

No blocking issues found.

github run

Gate installManualScrollRestoration on the document not being an App
Router one (window.next.appDir === true): with the now-global
__NEXT_SCROLL_RESTORATION define, an App Router app that value-imports
next/router for compat would otherwise have its native browser scroll
restoration disabled at module eval. Upstream only flips
history.scrollRestoration inside the Pages Router constructor, which
never runs on App Router documents.

Also document the hash-only popstate path discarding the per-entry
__next_scroll_<key> snapshot as intentional upstream parity (change()'s
onlyAHashChange branch passes null to this.set and only scrollToHash
runs), and add unit coverage for both behaviors.
@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.

Reviewed the Pages Router scroll-restoration changes. The runtime logic (history-key tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard, runtime-install ordering before window.next.router is exposed, the render-commit boundary, and the SSR-safe useNonWarningLayoutEffect) is sound and closely mirrors upstream Next.js v16.2.6. The substantive concerns from the prior review rounds all appear resolved in this version, and the unit coverage in shims.test.ts / next-config.test.ts is thorough.

One blocking finding the prior rounds missed: the new pages-scroll-restoration Playwright project — the ported regression test that is the headline validation of this PR — is defined in playwright.config.ts but never wired into CI, so it does not run on PRs or on main. Details inline. This should be fixed before merge, otherwise the regression this PR fixes is unguarded going forward.

No other blocking issues.

Comment thread playwright.config.ts
timeout: 60_000,
},
},
"pages-scroll-restoration": {

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.

Blocking: this new E2E project is never run in CI.

Every other key in projectServers has a matching entry in the E2E job matrix in .github/workflows/ci.yml (pages-router, pages-router-prod, app-router-bfcache, cloudflare-workers, static-export, etc.). pages-scroll-restoration is the only project added here without a corresponding matrix entry, so vp exec playwright test with PLAYWRIGHT_PROJECT=pages-scroll-restoration is never invoked in CI.

I confirmed this against the live PR checks — there is no E2E (pages-scroll-restoration) job in the status rollup. The result is that the ported Next.js regression test (reload-scroll-backforward-restoration.spec.ts), which is the central validation cited throughout the PR description, only runs when a maintainer manually exports PLAYWRIGHT_PROJECT=pages-scroll-restoration locally. After merge nothing guards against a regression in this exact behavior.

Please add the project to the matrix in .github/workflows/ci.yml (in the E2E job's matrix.include), e.g.:

          - project: pages-scroll-restoration
            label: pages-scroll-restoration
            shardIndex: 1
            shardTotal: 1

(Place it alongside the other single-shard Pages Router entries.)

// still restore the per-entry scroll saved in history state on push, since
// a soft popstate re-renders content the browser's native restoration
// can't position correctly.
const scrollTarget = manualScrollRestoration

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 manualScrollRestoration is off, a soft popstate to an entry that has no __vinext_scrollX/Y in state resolves scrollTarget to null (readScrollPosition(state) returns null), so renderPagesRouterElement does not scroll at all on that branch. On main the old restoreScrollPosition(e.state) was also a no-op for entries lacking the saved shape, so this is parity-preserving — but worth confirming the intent is "only restore entries we explicitly stamped on push," since unstamped entries (e.g. a back to the very first server-rendered entry whose state was never written by saveScrollPosition) now neither restore from history-state nor fall through to native auto restoration (native is still enabled when the flag is off, so the browser handles it). Just flagging that the two code paths (manualScrollRestoration ? ... : readScrollPosition(state)) have different fallbacks ({x:0,y:0} vs null) and the asymmetry is intentional.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

Reviewed PR #1905 (Pages Router scroll restoration). The runtime logic has already been hardened across 7 prior review rounds and is sound, but I found one material issue that those rounds (which focused on router.ts) missed:

Blocking finding — new E2E test never runs in CI:

  • playwright.config.ts:100 adds a pages-scroll-restoration Playwright project, but it was not added to the E2E job matrix in .github/workflows/ci.yml. Every other project key has a matching matrix entry; this one doesn't. I confirmed against the live PR status checks that there is no E2E (pages-scroll-restoration) job. The consequence: the ported Next.js regression test — the headline validation of the whole PR — only runs when a maintainer manually exports PLAYWRIGHT_PROJECT=pages-scroll-restoration locally, and nothing guards the fixed behavior after merge. Posted the exact matrix snippet to add.

Non-blocking observation:

  • router.ts:2419 — the flag-off popstate scroll path resolves to null (vs {x:0,y:0} in the flag-on path) for entries without a stamped scroll position. This is parity-preserving vs main, but flagged the asymmetry to confirm intent.

Verified resolved (from prior rounds): SSR useLayoutEffect warning, non-flag back/forward regression, render-commit canceller race, _currentHistoryKey-before-beforePopState ordering, and the fixture's Number(id) + 1 href (correctly produces /1, not /01).

github run

Add the missing E2E matrix entry so the ported Next.js regression spec
(reload-scroll-backforward-restoration.spec.ts) actually executes in CI,
matching the shape of the sibling single-shard Pages Router entries.

Also document why the soft-popstate scroll fallback differs between
manual restoration on ({ x: 0, y: 0 }) and off (null -> native auto
restoration handles unstamped entries), per review.
@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.

Reviewed the current HEAD (5bda29d). The opt-in Pages Router scroll-restoration path is well implemented and closely mirrors upstream Next.js v16.2.6: the experimental.scrollRestorationprocess.env.__NEXT_SCROLL_RESTORATION define plumbing, history-key tracking via _currentHistoryKey, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard, runtime-install ordering before window.next.router is exposed, the commit-boundary lifecycle, and the SSR-safe useNonWarningLayoutEffect.

Verified resolved — every substantive concern from the 8 prior review rounds is addressed in this version:

  • CI matrix: pages-scroll-restoration is now wired into the e2e job matrix (.github/workflows/ci.yml:305-308) — the round-8 blocker is fixed by the HEAD commit.
  • SSR useLayoutEffect warning → useNonWarningLayoutEffect (router.ts:95).
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y when the flag is off (router.ts:2425), with a unit test.
  • Render-commit canceller race → clearIfCurrent identity check (router.ts:150-152).
  • _currentHistoryKey-before-beforePopState ordering → committed after the cancel early-return (router.ts:2374).
  • App Router manual-restoration gate → installManualScrollRestoration checks window.next?.appDir (router.ts:216), with a unit test.
  • Fixture link increment → Number(id) + 1.

No blocking issues. One minor, non-blocking nuance below.

}
}

const manualScrollRestoration =

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 nuance: the module-level manualScrollRestoration constant does not include the window.next?.appDir check that installManualScrollRestoration (line 216) uses to keep manual restoration Pages-Router-only. The save/read sessionStorage branches in the popstate handler (lines 2341, 2350, 2353-2355) gate on this constant, not on the App Router check.

In practice this is harmless because the Pages Router popstate handler early-returns on any object state lacking __N: true (line 2299), and an App Router document's history entries are not stamped with the Pages Router state shape — so the sessionStorage writes never actually fire there. But the asymmetry means manualScrollRestoration being true on an App Router doc is relying on a second guard (the __N filter) rather than the same appDir gate used for the history.scrollRestoration flip. If a future change ever made the popstate path reachable for App Router state, the sessionStorage writes would leak. Worth a one-line comment noting that the __N filter is what keeps these branches Pages-only, or folding the appDir check into the constant for symmetry.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

Reviewed PR #1905 (Pages Router scroll restoration) at HEAD commit 5bda29d. This PR has been through 8 prior review rounds, and I verified that every substantive concern raised previously is resolved in the current version — including the round-8 blocker (the pages-scroll-restoration Playwright project not being wired into CI), which is fixed by the HEAD commit itself (.github/workflows/ci.yml:305-308).

Verified resolved:

  • CI matrix entry added for the new E2E project
  • SSR useLayoutEffect warning → useNonWarningLayoutEffect
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y
  • Render-commit canceller race → clearIfCurrent identity check
  • _currentHistoryKey-before-beforePopState ordering
  • App Router manual-restoration gate → window.next?.appDir check
  • Fixture link increment → Number(id) + 1

No blocking issues. I posted one minor, non-blocking observation: the module-level manualScrollRestoration constant omits the appDir check that installManualScrollRestoration uses, so the popstate sessionStorage save/read branches rely on the __N state filter (rather than the same appDir gate) to stay Pages-Router-only. It's harmless today but a latent asymmetry worth a comment.

The config→define plumbing, runtime-install ordering, commit-boundary lifecycle, and test coverage (unit + ported e2e regression test) are all sound and closely mirror upstream Next.js v16.2.6.

github run

…onstant

The popstate handler's sessionStorage save/read branches gated on the
module-level constant, which lacked the window.next?.appDir check that
installManualScrollRestoration used, leaving those branches relying
solely on the __N state filter to stay inert on App Router documents.
Fold the appDir check into the constant so every consumer is
Pages-Router-only by the same gate; the App Router bootstrap stamps
window.next.appDir before any client module evaluates, so the
module-eval-time check is reliable.
@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.

Independent review of the Pages Router scroll-restoration changes at HEAD. The implementation is sound and closely mirrors upstream Next.js v16.2.6: the experimental.scrollRestoration -> process.env.__NEXT_SCROLL_RESTORATION define plumbing, _currentHistoryKey tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (flag + appDir gate + history support + usable sessionStorage), runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle.

I verified the concerns from the prior review rounds are all resolved in this version, and additionally confirmed two things those rounds raised but did not fully close:

  • SSR error boundary is safe. PagesRouterCommitBoundary defines only componentDidCatch (no getDerivedStateFromError). componentDidCatch is a commit-phase lifecycle that does not run during renderToString/server rendering, so an SSR render error still propagates exactly as before — the boundary that now wraps every SSR call site (pages-page-handler.ts, pages-server-entry.ts, dev-server.ts) does not silently swallow server errors. Behavior is preserved.
  • Render-commit rejection path is wired correctly. A render error during client navigation rejects the commit promise via onError, which surfaces in runNavigateClient as "failed" and triggers the hard-navigation fallback exercised by the spec.

I ran vp check on router.ts (clean) and the targeted unit suites (shims.test.ts -t scroll, next-config.test.ts -t scrollRestoration) locally — all green. No blocking issues. One minor, non-blocking note inline.


export const getServerSideProps = (context) => {
const { id = 0 } = context.query;
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: getServerSideProps returns id defaulted to the number 0 when context.query.id is absent, but for a real route param id is always a string from the query ("0", "1", "error"...). The component's Number(id) + 1 href and the id === "error" check both rely on the string form, so the = 0 numeric default is only ever exercised when there is no id at all (not a route this fixture hits). Harmless, just an inconsistency between the numeric default and the string values actually flowing through. Matches the upstream fixture shape, so fine to leave.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

Reviewed PR #1905 (Pages Router scroll restoration) independently at HEAD 5bda29d. The implementation is sound and closely mirrors upstream Next.js v16.2.6.

Verified resolved (from the 9 prior review rounds):

  • CI matrix entry for pages-scroll-restoration (.github/workflows/ci.yml:305-308)
  • SSR useLayoutEffect warning → useNonWarningLayoutEffect
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y
  • Render-commit canceller race → clearIfCurrent identity check
  • _currentHistoryKey-before-beforePopState ordering
  • App Router gate → window.next?.appDir check folded into manualScrollRestoration

Two things I independently confirmed that prior rounds raised but didn't fully close:

  1. SSR error boundary is safePagesRouterCommitBoundary defines only componentDidCatch (no getDerivedStateFromError). Since componentDidCatch is a commit-phase lifecycle that doesn't run during renderToString, server render errors still propagate exactly as before across all the SSR call sites that wrapWithRouterContext now wraps. No silent swallowing.
  2. Render-commit rejection path — an onError rejection surfaces as "failed" in runNavigateClient and triggers the hard-navigation fallback the spec exercises.

Ran vp check on router.ts (clean) and the targeted unit suites locally — all green. No blocking issues. Posted one minor, non-blocking inline note on the fixture's getServerSideProps numeric id default.

github run

…ng "0"

Route params always arrive as strings, so the numeric 0 fallback was
inconsistent with the string values the component relies on
(Number(id) + 1 href, id === "error" check). Default to "0" for type
consistency.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

1 similar comment
@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.

Independent review of the Pages Router scroll-restoration changes. I read the current HEAD (not just the diff), traced the reload + back/forward + push paths through router.ts, verified the config→process.env.__NEXT_SCROLL_RESTORATION define plumbing, confirmed the CI matrix wiring, and ran the targeted unit suites locally (next-config.test.ts -t scrollRestoration, shims.test.ts -t "Pages Router concurrent navigation") — all green.

The runtime logic is sound. The 10 prior review rounds have thoroughly covered the substantive correctness concerns (SSR useLayoutEffect warning, non-flag back/forward regression, render-commit canceller race, _currentHistoryKey/beforePopState ordering, App Router appDir gate, CI matrix), and I independently confirmed each is resolved in the current tree. The reload path is correct: stampInitialHistoryState recovers _currentHistoryKey from the persisted history entry, and the __next_scroll_<key> sessionStorage snapshots survive reload, so back/forward after reload restores correctly.

One genuinely new finding the prior rounds missed: the new test fixture diverges from every other fixture's setup pattern. This is non-blocking (the e2e command builds dist first, so CI works), but it's a maintainability footgun worth fixing. Details inline.

No blocking issues.

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vinext from "../../../packages/vinext/dist/index.js";

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.

Fixture diverges from the established pattern (non-blocking, but fragile). Every other fixture imports vinext via the workspace package name (import vinext from "vinext") — including pages-basic, which the pages-router-prod Playwright project runs through the same prod-build flow (cli.js build && cli.js start) that this project uses. This fixture instead reaches into the built output by relative path:

Suggested change
import vinext from "../../../packages/vinext/dist/index.js";
import vinext from "vinext";

This only works because the project's command runs npx vp run vinext#build before serving, and because React resolves from the hoisted workspace root (this fixture's package.json declares no react/react-dom/vinext deps — see the next comment). Importing the package name keeps resolution consistent with the rest of the suite and avoids a stale-dist trap if someone runs the fixture without a fresh build.

"name": "fixture-pages-scroll-restoration",
"private": true,
"type": "module"
}

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.

This fixture declares no dependencies, unlike every other fixture (pages-basic declares react, react-dom, vinext: workspace:* plus vite/vite-plus devDeps). The build currently succeeds only because React and vinext resolve from the hoisted workspace root, and vite.config.ts reaches into dist by relative path. For parity and to make the fixture self-describing, add the same dependency block:

Suggested change
}
"type": "module",
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:",
"vinext": "workspace:*"
},
"devDependencies": {
"vite": "catalog:",
"vite-plus": "catalog:"
}
}

(Pairs with switching vite.config.ts to import vinext from "vinext".) Note the pnpm-lock.yaml entry for this fixture is currently empty ({}), reflecting the missing deps — adding them will populate it.

typeof window !== "undefined" &&
window.next?.appDir !== true &&
"scrollRestoration" in window.history &&
canUseSessionStorageForScrollRestoration();

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 confirmation: manualScrollRestoration is a module-eval constant, so the window.next?.appDir gate is read at the instant next/router first evaluates. The comment above asserts the App Router bootstrap stamps window.next.appDir = true before any client-component module evaluates — which the new does not flip history.scrollRestoration to manual on App Router documents test exercises. Worth keeping that test as the guardrail, since if bootstrap ordering ever changed (appDir stamped after the first next/router value-import), this constant would silently capture appDir !== true and flip manual restoration on an App Router doc. The runtime gate is correct given today's ordering; just flagging the eval-time coupling.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

Reviewed PR #1905 (Pages Router scroll restoration) independently at HEAD. I read the current tree (not just the diff), traced the reload/back/forward/push paths through router.ts, verified the config→define plumbing and CI matrix wiring, and ran the targeted unit suites locally — all green.

Runtime logic is sound. The 10 prior review rounds exhaustively covered the substantive correctness concerns, and I confirmed each is resolved in the current tree. I additionally verified the reload path works correctly: stampInitialHistoryState recovers _currentHistoryKey from the persisted history entry, and __next_scroll_<key> sessionStorage snapshots survive reload.

One genuinely new finding the prior rounds missed — the new pages-scroll-restoration test fixture diverges from every other fixture's setup pattern:

  1. vite.config.ts imports vinext by relative dist path instead of the workspace package name (import vinext from "vinext").
  2. package.json declares no dependencies (no react/react-dom/vinext), unlike pages-basic which runs the same prod-build e2e flow via the pages-router-prod project.

This is non-blocking (CI works because the e2e command builds dist first and React resolves from the hoisted workspace root), but it's a fragile, maintainability footgun. Posted suggestions to bring it in line with the established pattern.

I also posted one non-blocking confirmation note on the module-eval-time coupling of the manualScrollRestoration appDir gate.

No blocking issues.

github run

…with pages-basic

Import vinext via the workspace package name instead of reaching into
dist by relative path, and declare the same react/react-dom/vinext
dependencies as the other fixtures so the fixture is self-describing
and the pnpm-lock.yaml importer entry is populated.
@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.

Independent review of the Pages Router scroll-restoration changes at HEAD 53479ef. I read the current tree (router.ts, pages-router-runtime.ts, the config/define plumbing, the CI matrix, and the fixtures) rather than just the diff.

The implementation is sound and closely mirrors upstream Next.js v16.2.6. The experimental.scrollRestorationprocess.env.__NEXT_SCROLL_RESTORATION define plumbing (index.ts:1331, next-config.ts:1524), the _currentHistoryKey tracking, the __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (flag + appDir gate + history support + usable sessionStorage at router.ts:214), the runtime-install ordering before window.next.router is exposed (router.ts:2775), and the commit-boundary lifecycle all look correct.

I confirmed every substantive concern from the prior review rounds is resolved at this HEAD:

  • CI matrix entry for pages-scroll-restoration is present (.github/workflows/ci.yml:305-308), so the ported regression test actually runs in CI.
  • SSR useLayoutEffect warning → useNonWarningLayoutEffect (router.ts:95).
  • Non-flag back/forward regression → popstate honors __vinext_scrollX/Y from history state when the flag is off (router.ts:2428-2430), covered by a unit test.
  • Render-commit canceller race → clearIfCurrent identity check (router.ts:150-152, 164, 173).
  • _currentHistoryKey-before-beforePopState → commit is correctly placed after the cancel early-return (router.ts:2379-2381).
  • App Router gate folded into the manualScrollRestoration constant (router.ts:217), with a unit test.
  • Fixture deps/pnpm-lock.yaml populated and vite.config.ts imports the workspace package name.

One minor, non-blocking observation inline — a hash-only push under manual restoration does not snapshot the outgoing entry's scroll. Narrow edge, and broader hash-scroll coverage is an explicit non-goal of this PR.

No blocking issues found.

// non-shallow path, where the x/y reset runs inside the render-commit
// callback (also ahead of routeChangeComplete), mirroring Next.js's
// ordering of scroll-during-commit, then completion event.
if (doScroll) {

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 observation about the hash-only push branch (lines 2038-2048): it returns before saveScrollPosition() runs (that call is at line 2075, in the full-navigation path). updateHistory in the hash-only branch mints a new key and advances _currentHistoryKey, so the outgoing entry's live scroll is never written to its __next_scroll_<key> sessionStorage snapshot. A later back-popstate to that entry would then restore {x: 0, y: 0} (the forcedScroll default at line 2359) rather than the position the user had reached there.

This differs from the full-navigation push path (which snapshots via saveScrollPosition()) and from Next.js, which captures a scroll snapshot for every history entry transition. The PR lists broader hash-scroll coverage as an explicit non-goal and the fixture doesn't exercise hash-only push → back, so flagging rather than blocking — but if hash-only push entries should preserve scroll, snapshotting the outgoing entry before updateHistory in the hash-only branch would close it.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

I performed an independent review of PR #1905 (Pages Router scroll restoration) at HEAD 53479ef, reading the current tree rather than just the diff.

The implementation is sound and closely mirrors upstream Next.js v16.2.6 — the config→define plumbing, _currentHistoryKey tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (with the appDir gate), runtime-install ordering before window.next.router is exposed, and the commit-boundary lifecycle.

Verified every substantive concern from prior rounds is resolved at this HEAD: CI matrix wiring, the SSR useLayoutEffect warning, the non-flag back/forward regression, the render-commit canceller race, the _currentHistoryKey/beforePopState ordering, the App Router gate, and the fixture dependency setup.

One new minor, non-blocking observation posted inline: a hash-only push under manual restoration returns before saveScrollPosition(), so the outgoing entry's scroll is never snapshotted to sessionStorage — a later back-popstate to that entry restores {x:0,y:0} instead. This is a narrow edge, and broader hash-scroll coverage is an explicit non-goal of the PR.

No blocking issues found.

github run

Upstream Next.js snapshots the outgoing entry's scroll into
__next_scroll_<key> inside Router.push() itself, before change()'s
onlyAHashChange short-circuit, so hash-only pushes still capture the
departed entry's position. Our hash-only push branch returned before
saveScrollPosition() ran, so a later back-popstate to that entry
restored {x: 0, y: 0} instead of the position the user had reached.

Save the outgoing scroll before updateHistory mints the new key,
matching the full-navigation push path and upstream ordering.
@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.

Independent review of the Pages Router scroll-restoration changes. I read the current tree (router.ts, the config/index.ts define plumbing, the CI matrix, playwright.config.ts, and the new fixture) rather than just the diff, and traced the reload / back / forward / push / hash paths.

The implementation is sound and closely mirrors upstream Next.js v16.2.6. The experimental.scrollRestoration -> process.env.__NEXT_SCROLL_RESTORATION define plumbing (index.ts:1331, next-config.ts:1524), _currentHistoryKey tracking, __next_scroll_<key> sessionStorage snapshots, the history.scrollRestoration = "manual" opt-in guard (flag + appDir gate + history support + usable sessionStorage at router.ts:214), runtime-install ordering before window.next.router is exposed (router.ts:2780), the SSR-safe useNonWarningLayoutEffect, and the render-commit boundary all look correct.

Independently verified resolved — every substantive concern from the prior review rounds is addressed at this HEAD:

  • CI matrix entry for pages-scroll-restoration is present (.github/workflows/ci.yml:305-308), and the project's port 4185 is unique (no collision with the other 13 projects in playwright.config.ts), so the ported regression test actually runs in CI.
  • SSR useLayoutEffect warning -> useNonWarningLayoutEffect (router.ts:95).
  • Non-flag back/forward regression -> popstate honors __vinext_scrollX/Y from history state when the flag is off (router.ts:2438), with a unit test.
  • Render-commit canceller race -> clearIfCurrent identity check (router.ts:150-152, 164, 173).
  • _currentHistoryKey-before-beforePopState ordering -> committed after the cancel early-return (router.ts:2387-2389).
  • App Router gate folded into the manualScrollRestoration constant (router.ts:214-219), with a unit test.
  • Hash-only push outgoing-entry snapshot (router.ts:2047) and the deferred hash-scroll ordering (router.ts:2104-2109), both with unit tests.
  • Fixture deps + pnpm-lock.yaml populated, vite.config.ts imports the workspace package name.

No blocking issues found. One minor, non-blocking confirmation note inline.

// matching the old restoreScrollPosition no-op on main.
const scrollTarget = manualScrollRestoration
? (forcedScroll ?? readScrollPosition(state) ?? { x: 0, y: 0 })
: readScrollPosition(state);

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 confirmation: the precedence here means readScrollPosition(state) is only reachable on the manual branch when targetKey === undefined (otherwise forcedScroll is already ... ?? { x: 0, y: 0 } and short-circuits). That's the intended split — entries without a router key fall back to the history-state shape, keyed entries always resolve via the sessionStorage snapshot. Worth a one-line note that the forcedScroll/readScrollPosition(state)/{x:0,y:0} chain is structured so the middle term is the no-key fallback, since at a glance it reads as three independent fallbacks when the first already absorbs the third for keyed entries.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review submitted successfully.

Summary

I performed an independent review of PR #1905 (Pages Router scroll restoration) at HEAD, reading the current tree and tracing the reload / back / forward / push / hash code paths through router.ts, plus the config→define plumbing, CI matrix, playwright.config.ts, and the new fixture.

The implementation is sound and closely mirrors upstream Next.js v16.2.6:

  • experimental.scrollRestorationprocess.env.__NEXT_SCROLL_RESTORATION define plumbing
  • _currentHistoryKey tracking + __next_scroll_<key> sessionStorage snapshots
  • history.scrollRestoration = "manual" opt-in guard (flag + appDir gate + history support + usable sessionStorage)
  • runtime-install ordering before window.next.router is exposed
  • SSR-safe useNonWarningLayoutEffect and the render-commit boundary

Independently verified resolved — every substantive concern from the 13 prior review rounds is addressed at this HEAD, including the round-8 CI-wiring blocker. I additionally confirmed the new project's port 4185 is unique among the 14 Playwright projects, so the ported regression test runs in CI without collision.

No blocking issues found. I posted one minor, non-blocking confirmation note inline on router.ts:2438, suggesting a one-line comment to clarify that the forcedScroll ?? readScrollPosition(state) ?? {x:0,y:0} chain is structured so the middle term is specifically the no-key fallback (it reads at a glance as three independent fallbacks when the first already absorbs the third for keyed entries).

github run

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