Skip to content

fix(app-router): skip RSC navigation for hash-only history traversal#1270

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/hash-popstate-rsc
May 16, 2026
Merged

fix(app-router): skip RSC navigation for hash-only history traversal#1270
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/hash-popstate-rsc

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Avoid App Router RSC traversal work for browser back/forward between hash-only history entries.
Core change Short-circuit App Router popstate when the target pathname and search already match the committed router state, then restore scroll directly.
Main boundary Hash fragments are client-only URL state and are not part of the committed App Router RSC route identity.
Primary files packages/vinext/src/server/app-browser-entry.ts, tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts
Expected impact Back/forward between anchors on the same App Router page no longer calls __VINEXT_RSC_NAVIGATE__ or replays cached RSC payloads.

Why

For App Router correctness, a history traversal should only enter the RSC navigation lifecycle when the route identity changes. Hash-only entries preserve the same pathname and search, so the browser should update location and scroll without asking React Flight to rebuild the route tree.

Area Principle / invariant What this PR changes
App Router popstate Hash is not part of the RSC route key. Compares the popstate target pathname/search to the committed App Router snapshot before dispatching traversal.
Scroll restoration Same-route fragment traversal still needs browser-visible scroll behavior. Calls the existing App Router popstate scroll restoration directly on the no-RSC path.
Next.js parity Next restores history state and classifies same-path/search hash changes as onlyHashChange. Adds a regression that asserts hash back/forward does not call vinext's RSC navigation function.

What changed

Scenario Before After
/page#section-a to /page#section-b via browser buttons App Router popstate always called __VINEXT_RSC_NAVIGATE__. Same pathname/search traversal restores scroll without RSC navigation.
Real route traversal Entered the existing RSC traversal path. Still enters the existing RSC traversal path.
Maintainer review path
  1. packages/vinext/src/server/app-browser-entry.ts
    • Review isSameAppRoutePopstateTarget() and the popstate branch. The key question is whether committed pathname/search is the right App Router boundary for skipping RSC work.
  2. tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts
    • Review the new regression that wraps window.__VINEXT_RSC_NAVIGATE__ and verifies hash back/forward traversal keeps the call count at zero while preserving URL and scroll behavior.
Validation
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts
  • vp test run tests/app-browser-entry.test.ts
  • vp check

The new E2E failed before the implementation with 2 RSC navigation calls, one for back and one for forward.

Risk / compatibility
  • Public API: none.
  • Runtime: narrows App Router popstate work only when the target pathname/search matches the committed App Router route snapshot.
  • RSC/cache: avoids visited-response replay for same-route fragment traversal; real route traversal keeps the existing cache and pending-scroll coordination.
  • Compatibility: aligns with Next.js treating same-path/search hash changes as hash-only navigation.
Non-goals
  • Does not change Pages Router popstate behavior.
  • Does not attempt broader native History API parity for search-only pushState/replaceState entries.
  • Does not change hash scrolling semantics for links or programmatic router navigation.

References

Reference Why it matters
Next.js App Router popstate handler Shows browser back/forward dispatches restore against stored App Router history state.
Next.js segment-cache navigation onlyHashChange Shows same pathname/search with a changed hash is classified separately for scroll handling.
Next.js hash navigation E2E Covers App Router hash navigation behavior, including hash-only link cases.
Next.js Link scroll docs Documents hash-scroll behavior and scroll={false} interaction.

App Router popstate currently sends every history traversal through __VINEXT_RSC_NAVIGATE__, even when the target pathname and search already match the committed route and only the hash entry changed. That violates the hash-only traversal invariant and replays cached RSC payloads unnecessarily.\n\nCompare popstate targets against the committed App Router pathname and search before dispatching an RSC traversal. Same-route entries now restore scroll directly, while real route traversals keep the existing pending-navigation coordination.\n\nAdd an App Router E2E regression that wraps __VINEXT_RSC_NAVIGATE__ and verifies hash back/forward traversal does not call it.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1270

commit: 66fcb51

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 13:12
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped fix. The implementation correctly identifies hash-only popstate traversals by comparing the target pathname/search against the committed App Router snapshot, and short-circuits RSC navigation while still restoring scroll. Two minor observations below — neither blocking.

const target = new URL(href, window.location.origin);
const routerState = getBrowserRouterState();
const targetPathname = stripBasePath(target.pathname, __basePath);
const targetSearch = new URLSearchParams(target.search).toString();
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.

Nit: new URLSearchParams(target.search).toString() round-trips the search string through URLSearchParams normalization (sorts nothing, but does strip a leading ? and re-encode). Since the committed snapshot's searchParams was also constructed via new ReadonlyURLSearchParams(url.search) (in createClientNavigationRenderSnapshot), both sides go through the same normalization, so this works correctly.

If you wanted to shave the allocation, target.search (which includes the ?) could be compared against a pre-stored raw search string, but the URLSearchParams approach is more robust against encoding differences, so this is fine as-is.

window.addEventListener("popstate", (event) => {
notifyAppRouterTransitionStart(window.location.href, "traverse");
const href = window.location.href;
notifyAppRouterTransitionStart(href, "traverse");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth noting: notifyAppRouterTransitionStart still fires on the early-return path (before the isSameAppRoutePopstateTarget check). I verified there's no corresponding transitionEnd callback, so this is fine — it's fire-and-forget instrumentation. Just calling it out for future readers: if a transition-end hook is ever added, the early-return path will need to emit it too.

await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`);
await expect(page.locator("#content")).toBeInViewport();

await expect
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 use of expect.poll here — it handles the case where the assertion runs before the popstate handler has fully completed. The goBack/goForward awaits above ensure the URL has changed, but expect.poll makes the RSC-call-count assertion robust against any remaining async work.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of my findings:

Approved. The PR is clean and well-scoped.

What it does: Adds isSameAppRoutePopstateTarget() that compares the popstate target's pathname/search against the committed App Router navigationSnapshot. When they match (hash-only difference), it short-circuits the RSC navigation and directly restores scroll position.

Key verification points:

  • The search params comparison is sound — both sides go through URLSearchParams normalization (new URLSearchParams(target.search).toString() vs routerState.navigationSnapshot.searchParams.toString() where searchParams is a ReadonlyURLSearchParams extends URLSearchParams)
  • stripBasePath is correctly applied to the target pathname before comparison with the snapshot pathname (which is already base-path-stripped per createClientNavigationRenderSnapshot)
  • notifyAppRouterTransitionStart fires before the early return but has no corresponding end callback, so no state leak
  • __VINEXT_RSC_PENDING__ is not set on the early return path, which is correct — the Pages Router popstate handler in navigation.ts is guarded by typeof window.__VINEXT_RSC_NAVIGATE__ !== "function" so it won't run in App Router context
  • The E2E test properly wraps __VINEXT_RSC_NAVIGATE__ to count calls and uses expect.poll for robustness against async timing

github run

@james-elicx james-elicx enabled auto-merge (squash) May 16, 2026 18:57
@james-elicx james-elicx merged commit d280e36 into cloudflare:main May 16, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants