From 146c100c0e9abf821ad61ae68c5f987bd2660eff Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 18 May 2026 19:55:43 +1000 Subject: [PATCH 1/2] fix(app-router): track hash-only traversal metadata Hash-only App Router navigations wrote null history state even though they are app-owned browser-visible entries. That kept traversal direction unknown, but it broke the metadata invariant introduced by the App Router navigation lifecycle work. Route hash-only push and replace through a browser-entry-owned commit hook so the same traversal index allocator and metadata shape are used without fetching RSC. Also update the local traversal index during same-route hash popstate so replace-after-back uses the current entry index. Closes #1252 --- packages/vinext/src/global.d.ts | 10 ++++ .../vinext/src/server/app-browser-entry.ts | 31 +++++++++++++ packages/vinext/src/shims/navigation.ts | 20 ++++++-- .../hash-popstate-scroll.spec.ts | 46 +++++++++++++++++++ .../hash-popstate-scroll/hash-actions.tsx | 13 ++++++ .../hash-popstate-scroll/page.tsx | 2 + 6 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 0435b7f20..2d82b2761 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -120,6 +120,16 @@ declare global { */ __VINEXT_CLEAR_NAV_CACHES__: (() => void) | undefined; + /** + * Commits an App Router hash-only navigation without fetching RSC data. + * Installed by the browser RSC entry so `next/navigation` and `next/link` + * can route app-owned hash-only history writes through the same vinext + * history metadata/index allocator as approved RSC commits. + */ + __VINEXT_RSC_COMMIT_HASH_NAVIGATION__: + | ((href: string, historyUpdateMode: "push" | "replace") => void) + | undefined; + /** * A Promise that resolves when the current in-flight popstate RSC navigation * finishes rendering. diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 9eb0c0dc2..d279ac08c 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -226,6 +226,35 @@ function commitHistoryTraversalIndex(index: number | null): void { } } +function commitHashOnlyNavigation( + href: string, + historyUpdateMode: Exclude, +): void { + const preserveExistingState = historyUpdateMode === "replace"; + const navigationHistoryIndex = allocateNavigationHistoryTraversalIndex(historyUpdateMode); + const previousNextUrl = hasBrowserRouterState() + ? getBrowserRouterState().previousNextUrl + : readHistoryStatePreviousNextUrl(window.history.state); + const historyState = createHistoryStateWithNavigationMetadata( + preserveExistingState ? window.history.state : null, + { + previousNextUrl, + traversalIndex: navigationHistoryIndex, + }, + ); + + if (historyUpdateMode === "replace") { + replaceHistoryStateWithoutNotify(historyState, "", href); + } else { + pushHistoryStateWithoutNotify(historyState, "", href); + } + commitHistoryTraversalIndex(navigationHistoryIndex); +} + +function commitTraversalIndexFromHistoryState(historyState: unknown): void { + commitHistoryTraversalIndex(readHistoryStateTraversalIndex(historyState)); +} + function getBrowserRouterState(): AppRouterState { return browserNavigationController.getBrowserRouterState(); } @@ -1113,6 +1142,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { // header comment: "the segment cache contains the actual RSC data which // needs to be re-fetched." window.__VINEXT_CLEAR_NAV_CACHES__ = clearClientNavigationCaches; + window.__VINEXT_RSC_COMMIT_HASH_NAVIGATION__ = commitHashOnlyNavigation; window.__VINEXT_RSC_NAVIGATE__ = async function navigateRsc( href: string, @@ -1475,6 +1505,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { const href = window.location.href; if (isSameAppRoutePopstateTarget(href)) { notifyAppRouterTransitionStart(href, "traverse"); + commitTraversalIndexFromHistoryState(event.state); restorePopstateScrollPosition(event.state); return; } diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 7649be0fc..4ceea3973 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1214,6 +1214,20 @@ function saveScrollPosition(): void { ); } +function commitHashOnlyHistoryState(href: string, mode: "push" | "replace"): void { + const commitAppRouterHashNavigation = window.__VINEXT_RSC_COMMIT_HASH_NAVIGATION__; + if (commitAppRouterHashNavigation) { + commitAppRouterHashNavigation(href, mode); + return; + } + + if (mode === "replace") { + replaceHistoryStateWithoutNotify(null, "", href); + } else { + pushHistoryStateWithoutNotify(null, "", href); + } +} + /** * Restore scroll position from a history state object (used on popstate). * @@ -1297,11 +1311,7 @@ export async function navigateClientSide( // Hash-only change: update URL and scroll to target, skip RSC fetch if (isHashOnlyChange(fullHref)) { const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : ""; - if (mode === "replace") { - replaceHistoryStateWithoutNotify(null, "", fullHref); - } else { - pushHistoryStateWithoutNotify(null, "", fullHref); - } + commitHashOnlyHistoryState(fullHref, mode); commitClientNavigationState(); if (scroll) { scrollToHashTarget(hash); 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 363f74059..1848bd57a 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 @@ -5,6 +5,16 @@ import { waitForAppRouterHydration } from "../../helpers"; const BASE = "http://localhost:4174"; test.describe("Next.js compat: hash popstate scroll", () => { + async function readVinextHistoryIndex(page: Page) { + return page.evaluate(() => { + const state = window.history.state; + if (!state || typeof state !== "object") return null; + + const value = (state as Record).__vinext_historyIndex; + return typeof value === "number" ? value : null; + }); + } + async function expectScrollY(page: Page, expected: number) { await expect(async () => { const scrollY = await page.evaluate(() => window.scrollY); @@ -58,6 +68,42 @@ test.describe("Next.js compat: hash popstate scroll", () => { await expect(page.locator("#content")).toBeInViewport(); }); + test("hash-only Link entries carry vinext traversal metadata", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/hash-popstate-scroll`); + await waitForAppRouterHydration(page); + await expect(page.locator("h1")).toHaveText("Hash Popstate Scroll"); + expect(await readVinextHistoryIndex(page)).toBe(0); + + await page.click("#hash-link"); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + expect(await readVinextHistoryIndex(page)).toBe(1); + + await page.goBack(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`); + expect(await readVinextHistoryIndex(page)).toBe(0); + + await page.goForward(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + expect(await readVinextHistoryIndex(page)).toBe(1); + }); + + test("hash-only replace after back keeps the current traversal index", 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`); + expect(await readVinextHistoryIndex(page)).toBe(1); + + await page.goBack(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`); + expect(await readVinextHistoryIndex(page)).toBe(0); + + await page.click("#replace-top"); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#top`); + expect(await readVinextHistoryIndex(page)).toBe(0); + }); + // 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. diff --git a/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx new file mode 100644 index 000000000..a768dd25a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export function HashActions() { + const router = useRouter(); + + return ( + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/page.tsx index 23500760b..5535a5ead 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { HashActions } from "./hash-actions"; export default function HashPopstateScrollPage() { return ( @@ -17,6 +18,7 @@ export default function HashPopstateScrollPage() { Go to top +
From 8f87615e5c9308e04322e65b9ec829719f9e2a37 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 18 May 2026 20:09:58 +1000 Subject: [PATCH 2/2] fix(app-router): avoid stale hash replace scroll state Hash-only App Router replace now passes the effective scroll option into the browser-entry commit hook. Default-scroll replaces strip vinext scroll restoration keys before adding traversal metadata, avoiding contradictory history entries whose URL points at a hash target but whose state restores the old scroll position. Explicit scroll:false keeps the previous scroll restoration state. Add browser coverage for replace-after-back to #content, including the traversal back to the replaced entry. --- packages/vinext/src/global.d.ts | 2 +- .../vinext/src/server/app-browser-entry.ts | 30 ++++++++++++++++-- packages/vinext/src/shims/navigation.ts | 6 ++-- .../hash-popstate-scroll.spec.ts | 31 +++++++++++++++++++ .../hash-popstate-scroll/hash-actions.tsx | 11 +++++-- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 2d82b2761..5f3eec288 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -127,7 +127,7 @@ declare global { * history metadata/index allocator as approved RSC commits. */ __VINEXT_RSC_COMMIT_HASH_NAVIGATION__: - | ((href: string, historyUpdateMode: "push" | "replace") => void) + | ((href: string, historyUpdateMode: "push" | "replace", scroll: boolean) => void) | undefined; /** diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index d279ac08c..03b3cec13 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -229,14 +229,14 @@ function commitHistoryTraversalIndex(index: number | null): void { function commitHashOnlyNavigation( href: string, historyUpdateMode: Exclude, + scroll: boolean, ): void { - const preserveExistingState = historyUpdateMode === "replace"; const navigationHistoryIndex = allocateNavigationHistoryTraversalIndex(historyUpdateMode); const previousNextUrl = hasBrowserRouterState() ? getBrowserRouterState().previousNextUrl : readHistoryStatePreviousNextUrl(window.history.state); const historyState = createHistoryStateWithNavigationMetadata( - preserveExistingState ? window.history.state : null, + createHashOnlyNavigationBaseHistoryState(historyUpdateMode, scroll), { previousNextUrl, traversalIndex: navigationHistoryIndex, @@ -251,6 +251,32 @@ function commitHashOnlyNavigation( commitHistoryTraversalIndex(navigationHistoryIndex); } +function createHashOnlyNavigationBaseHistoryState( + historyUpdateMode: Exclude, + scroll: boolean, +): unknown { + if (historyUpdateMode !== "replace") { + return null; + } + return scroll ? stripVinextScrollState(window.history.state) : window.history.state; +} + +function stripVinextScrollState(state: unknown): unknown { + if (!state || typeof state !== "object") { + return state; + } + + const nextState: Record = {}; + for (const [key, value] of Object.entries(state)) { + if (key === "__vinext_scrollX" || key === "__vinext_scrollY") { + continue; + } + nextState[key] = value; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + function commitTraversalIndexFromHistoryState(historyState: unknown): void { commitHistoryTraversalIndex(readHistoryStateTraversalIndex(historyState)); } diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 4ceea3973..093efd68d 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1214,10 +1214,10 @@ function saveScrollPosition(): void { ); } -function commitHashOnlyHistoryState(href: string, mode: "push" | "replace"): void { +function commitHashOnlyHistoryState(href: string, mode: "push" | "replace", scroll: boolean): void { const commitAppRouterHashNavigation = window.__VINEXT_RSC_COMMIT_HASH_NAVIGATION__; if (commitAppRouterHashNavigation) { - commitAppRouterHashNavigation(href, mode); + commitAppRouterHashNavigation(href, mode, scroll); return; } @@ -1311,7 +1311,7 @@ export async function navigateClientSide( // Hash-only change: update URL and scroll to target, skip RSC fetch if (isHashOnlyChange(fullHref)) { const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : ""; - commitHashOnlyHistoryState(fullHref, mode); + commitHashOnlyHistoryState(fullHref, mode, scroll); commitClientNavigationState(); if (scroll) { scrollToHashTarget(hash); 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 1848bd57a..15ba6678a 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 @@ -104,6 +104,37 @@ test.describe("Next.js compat: hash popstate scroll", () => { expect(await readVinextHistoryIndex(page)).toBe(0); }); + test("hash-only replace after back restores scroll for the replaced hash target", 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`); + expect(await readVinextHistoryIndex(page)).toBe(1); + + await page.goBack(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll`); + await expectScrollY(page, 0); + expect(await readVinextHistoryIndex(page)).toBe(0); + + await page.click("#replace-content"); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + await expect(page.locator("#content")).toBeInViewport(); + expect(await readVinextHistoryIndex(page)).toBe(0); + + await page.goForward(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + await expect(page.locator("#content")).toBeInViewport(); + expect(await readVinextHistoryIndex(page)).toBe(1); + + await page.goBack(); + await expect(page).toHaveURL(`${BASE}/nextjs-compat/hash-popstate-scroll#content`); + await expect(page.locator("#content")).toBeInViewport(); + expect(await readVinextHistoryIndex(page)).toBe(0); + }); + // 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. diff --git a/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx index a768dd25a..33a58401a 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/hash-popstate-scroll/hash-actions.tsx @@ -6,8 +6,13 @@ export function HashActions() { const router = useRouter(); return ( - + <> + + + ); }