From 954ae96333767790890992bf0dd0698af03ac2ee Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 23:10:59 +1000 Subject: [PATCH] fix(app-router): skip RSC navigation for hash-only history traversal 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. --- .../vinext/src/server/app-browser-entry.ts | 30 +++++++++++- .../hash-popstate-scroll.spec.ts | 46 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..99b4e8093 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -78,6 +78,7 @@ import { import { DevRecoveryBoundary, RedirectBoundary } from "vinext/shims/error-boundary"; import { AppRouterContext } from "vinext/shims/internal/app-router-context"; import { ElementsContext, Slot } from "vinext/shims/slot"; +import { stripBasePath } from "../utils/base-path.js"; import { createOnUncaughtError } from "./app-browser-error.js"; import { devOnCaughtError, @@ -670,6 +671,20 @@ function restorePopstateScrollPosition(state: unknown): void { }); } +function isSameAppRoutePopstateTarget(href: string): boolean { + if (!hasBrowserRouterState()) return false; + + const target = new URL(href, window.location.origin); + const routerState = getBrowserRouterState(); + const targetPathname = stripBasePath(target.pathname, __basePath); + const targetSearch = new URLSearchParams(target.search).toString(); + const currentSearch = routerState.navigationSnapshot.searchParams.toString(); + + return ( + targetPathname === routerState.navigationSnapshot.pathname && targetSearch === currentSearch + ); +} + // Set on pagehide so the RSC navigation catch block can distinguish expected // fetch aborts (triggered by the unload itself) from real errors worth logging. let isPageUnloading = false; @@ -1330,9 +1345,20 @@ function bootstrapHydration(rscStream: ReadableStream): void { // microtask-based deferral for compatibility with non-RSC navigation. // See: https://github.com/vercel/next.js/discussions/41934#discussioncomment-4602607 window.addEventListener("popstate", (event) => { - notifyAppRouterTransitionStart(window.location.href, "traverse"); + const href = window.location.href; + notifyAppRouterTransitionStart(href, "traverse"); + + // The browser has already applied the history entry by the time popstate + // fires. App Router state does not include hashes, so matching the + // committed pathname/search proves this traversal does not need a new RSC + // payload. This covers both /page#target -> /page and /page -> /page#target. + if (isSameAppRoutePopstateTarget(href)) { + restorePopstateScrollPosition(event.state); + return; + } + const pendingNavigation = - window.__VINEXT_RSC_NAVIGATE__?.(window.location.href, 0, "traverse") ?? Promise.resolve(); + window.__VINEXT_RSC_NAVIGATE__?.(href, 0, "traverse") ?? Promise.resolve(); window.__VINEXT_RSC_PENDING__ = pendingNavigation; void pendingNavigation.finally(() => { restorePopstateScrollPosition(event.state); diff --git a/tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts b/tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts index 138df21cb..363f74059 100644 --- a/tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts @@ -58,6 +58,52 @@ test.describe("Next.js compat: hash popstate scroll", () => { await expect(page.locator("#content")).toBeInViewport(); }); + // Next.js App Router handles popstate with ACTION_RESTORE and classifies + // same-path/search fragment changes as onlyHashChange in segment-cache + // navigation, avoiding a new RSC payload for hash-only traversal. + // Source: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/segment-cache/navigation.ts + test("back/forward traversal between hash entries skips RSC navigation", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/hash-popstate-scroll`); + await waitForAppRouterHydration(page); + + await page.click("#hash-link"); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + + await page.evaluate(() => { + const testWindow = window as Window & { __vinextHashPopstateRscCalls?: number }; + const originalNavigate = window.__VINEXT_RSC_NAVIGATE__; + if (typeof originalNavigate !== "function") { + throw new Error("__VINEXT_RSC_NAVIGATE__ is not installed"); + } + window.__VINEXT_RSC_NAVIGATE__ = async (...args) => { + testWindow.__vinextHashPopstateRscCalls = + (testWindow.__vinextHashPopstateRscCalls ?? 0) + 1; + return originalNavigate(...args); + }; + testWindow.__vinextHashPopstateRscCalls = 0; + }); + + await page.goBack(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`); + await expectScrollY(page, 0); + + await page.goForward(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + await expect(page.locator("#content")).toBeInViewport(); + + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __vinextHashPopstateRscCalls?: number }) + .__vinextHashPopstateRscCalls ?? 0, + ), + ) + .toBe(0); + }); + test("forward traversal decodes non-latin hash fragments", async ({ page }) => { await expectHashForwardTraversal(page, "#encoded-link", "#caf%C3%A9", '[id="café"]'); });