Skip to content

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

Merged
james-elicx merged 22 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/reload-scroll-backforward-restoration
Jun 12, 2026
Merged

fix(pages-router): restore scroll across reload history traversal#1905
james-elicx merged 22 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.

@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: 302e29d

@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

james-elicx added a commit to NathanDrake2406/vinext that referenced this pull request Jun 11, 2026
…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

@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

@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

@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

@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

@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

@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 at HEAD 1077acf (newer than the commits earlier rounds referenced). I read the current tree rather than the diff alone, traced the reload / back / forward / push / hash code paths through router.ts, verified the config→process.env.__NEXT_SCROLL_RESTORATION define plumbing (index.ts:1331, next-config.ts:1524), confirmed the CI matrix wiring (ci.yml:305-308), and verified the reload-recovery path (stampInitialHistoryState recovers _currentHistoryKey from the persisted entry at router.ts:531).

The runtime implementation is sound and closely mirrors upstream Next.js v16.2.6: _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, the commit-boundary lifecycle, and the SSR-safe useNonWarningLayoutEffect. Every substantive correctness concern from the prior review rounds (SSR useLayoutEffect warning, non-flag back/forward regression, render-commit canceller race via clearIfCurrent, _currentHistoryKey/beforePopState ordering, App Router appDir gate, CI matrix wiring, hash-only push snapshot) is resolved at this HEAD.

One blocking, mechanical finding (still unresolved): the PR is in a conflicting merge state with no CI running. The GitHub API reports mergeable: CONFLICTING / mergeStateStatus: DIRTY and an empty statusCheckRollup at HEAD 1077acf — the required checks (Check, Vitest, Playwright E2E) are not running on this branch. main has advanced materially since the branch point — notably fix(router): support experimental gesture push (#1909) and perf(server): eliminate redundant per-request parsing in the request pipeline (#1929), the former of which edits router.ts (the file with the largest change here, +373/-51) — so the conflict is real, not stale metadata. This needs a rebase onto main so conflicts resolve and the newly-wired pages-scroll-restoration E2E project (this PR's headline validation) actually runs before merge. The runtime logic being correct is moot until CI can verify it on a mergeable tree.

No new code-level blocking issues found.

@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 1077acf, reading the current tree and tracing the reload / back / forward / push / hash paths through router.ts.

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

  • experimental.scrollRestorationprocess.env.__NEXT_SCROLL_RESTORATION define plumbing (index.ts:1331, next-config.ts:1524)
  • _currentHistoryKey tracking + __next_scroll_<key> sessionStorage snapshots
  • 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
  • SSR-safe useNonWarningLayoutEffect and the render-commit boundary
  • reload recovery: stampInitialHistoryState recovers _currentHistoryKey from the persisted entry (router.ts:531)

Verified resolved — every substantive concern from the prior 17 review rounds, including the round-8 CI-wiring blocker (ci.yml:305-308 is present).

One blocking, mechanical finding: the PR is in a conflicting merge state. The GitHub API reports mergeable: CONFLICTING / mergeStateStatus: DIRTY with an empty statusCheckRollup — required checks aren't running. main has advanced (#1909 edits router.ts, the file with the largest change here), so the conflict is real. A rebase is needed so the headline pages-scroll-restoration E2E test can actually run in CI before merge.

No new code-level blocking issues found.

github run

@NathanDrake2406
NathanDrake2406 force-pushed the nathan/reload-scroll-backforward-restoration branch 2 times, most recently from afd7886 to 1077acf Compare June 12, 2026 05:59
NathanDrake2406 and others added 22 commits June 12, 2026 15:59
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.
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.
…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
…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".
…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.
…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.
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.
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.
…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.
…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.
…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.
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.
@NathanDrake2406
NathanDrake2406 force-pushed the nathan/reload-scroll-backforward-restoration branch from 1077acf to 302e29d Compare June 12, 2026 06:00
@james-elicx
james-elicx merged commit e018a08 into cloudflare:main Jun 12, 2026
42 checks passed
@NathanDrake2406
NathanDrake2406 deleted the nathan/reload-scroll-backforward-restoration branch June 13, 2026 13:25
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