From ee5604e238e108928804e5678fe0b7e22bfed4af Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Thu, 6 Aug 2026 23:06:53 +0530 Subject: [PATCH 1/5] fix(app-router): restore shallow history pathname --- .../vinext/src/server/app-browser-entry.ts | 10 ++++++ .../vinext/src/server/app-history-state.ts | 31 ++++++++++++++----- packages/vinext/src/shims/navigation.ts | 12 +++++-- tests/e2e/app-router/advanced.spec.ts | 24 ++++++++++++++ tests/shims.test.ts | 6 ++++ 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f5be6d0cd6..2a0644720e 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -126,6 +126,7 @@ import { type OperationLane, } from "./app-browser-state.js"; import { AppBrowserHistoryController } from "./app-browser-history-controller.js"; +import { readHistoryStateShallowUrl } from "./app-history-state.js"; import { createVisitedResponseCacheEntry, deleteVisitedResponseCacheEntry, @@ -2454,9 +2455,18 @@ function bootstrapHydration( // Notify the transition start so observers still see the URL change, then // restore scroll directly and skip the RSC dispatch. const href = window.location.href; + const shallowUrl = readHistoryStateShallowUrl(event.state); + if (shallowUrl !== null && new URL(shallowUrl, href).href === href) { + notifyAppRouterTransitionStart(href, "traverse"); + historyController.commitTraversalIndexFromHistoryState(event.state); + commitClientNavigationState(); + restorePopstateScrollPosition(event.state); + return; + } if (isSameAppRoutePopstateTarget(href)) { notifyAppRouterTransitionStart(href, "traverse"); historyController.commitTraversalIndexFromHistoryState(event.state); + commitClientNavigationState(); restorePopstateScrollPosition(event.state); return; } diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index 2c542f40ce..0fe3f45322 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -6,6 +6,7 @@ const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; const VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY = "__vinext_historyIndex"; const VINEXT_BFCACHE_IDS_HISTORY_STATE_KEY = "__vinext_bfcacheIds"; const VINEXT_BFCACHE_VERSION_HISTORY_STATE_KEY = "__vinext_bfcacheVersion"; +const VINEXT_SHALLOW_URL_HISTORY_STATE_KEY = "__vinext_shallowUrl"; type HistoryStateRecord = { [key: string]: unknown; @@ -246,22 +247,38 @@ export function createHistoryStateWithNavigationMetadata( export function createExternalHistoryStatePreservingMetadata( callerState: unknown, currentHistoryState: unknown, + shallowUrl?: string, ): unknown { const previousNextUrl = readHistoryStatePreviousNextUrl(currentHistoryState); const traversalIndex = readHistoryStateTraversalIndex(currentHistoryState); const bfcacheIds = readHistoryStateBfcacheIds(currentHistoryState); const bfcacheVersion = readHistoryStateBfcacheVersion(currentHistoryState); - if (previousNextUrl === null && traversalIndex === null && bfcacheIds === null) { + if ( + previousNextUrl === null && + traversalIndex === null && + bfcacheIds === null && + shallowUrl === undefined + ) { return callerState; } - return createHistoryStateWithNavigationMetadata(callerState, { - bfcacheIds, - bfcacheVersion: bfcacheIds === null ? undefined : bfcacheVersion, - previousNextUrl, - traversalIndex, - }); + const nextState = + createHistoryStateWithNavigationMetadata(callerState, { + bfcacheIds, + bfcacheVersion: bfcacheIds === null ? undefined : bfcacheVersion, + previousNextUrl, + traversalIndex, + }) ?? {}; + if (shallowUrl !== undefined) { + nextState[VINEXT_SHALLOW_URL_HISTORY_STATE_KEY] = shallowUrl; + } + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function readHistoryStateShallowUrl(state: unknown): string | null { + const value = readHistoryStateRecord(state)?.[VINEXT_SHALLOW_URL_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; } export function readHistoryStatePreviousNextUrl(state: unknown): string | null { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index a698766125..dbe94e60f8 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -3027,7 +3027,11 @@ if (!isServer) { ): void { state.originalPushState.call( window.history, - createExternalHistoryStatePreservingMetadata(data, window.history.state), + createExternalHistoryStatePreservingMetadata( + data, + window.history.state, + new URL(url ?? window.location.href, window.location.href).href, + ), unused, url, ); @@ -3047,7 +3051,11 @@ if (!isServer) { ): void { state.originalReplaceState.call( window.history, - createExternalHistoryStatePreservingMetadata(data, window.history.state), + createExternalHistoryStatePreservingMetadata( + data, + window.history.state, + new URL(url ?? window.location.href, window.location.href).href, + ), unused, url, ); diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index f47b6abb26..2c354aebd3 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -669,6 +669,30 @@ test.describe("Shallow Routing (history.pushState/replaceState)", () => { ); }); + test("pushState pathname is restored by browser back and forward", async ({ page }) => { + // Ported from Next.js's shallow-routing compatibility coverage: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts + await page.goto(`${BASE}/shallow-test`); + + await page.waitForFunction( + () => typeof (window as any).__VINEXT_RSC_ROOT__ !== "undefined", + null, + { timeout: 10000 }, + ); + + const pathname = page.locator('[data-testid="pathname"]'); + await expect(pathname).toHaveText("pathname: /shallow-test"); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await expect(pathname).toHaveText("pathname: /shallow-test/sub", { timeout: 10_000 }); + + await page.goBack(); + await expect(pathname).toHaveText("pathname: /shallow-test", { timeout: 10_000 }); + + await page.goForward(); + await expect(pathname).toHaveText("pathname: /shallow-test/sub", { timeout: 10_000 }); + }); + test.fixme("multiple pushState calls update search params correctly", async ({ page }) => { await page.goto(`${BASE}/shallow-test`); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d0619f5287..686922bcac 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -797,6 +797,7 @@ describe("next/navigation shim", () => { const previousWindow = (globalThis as any).window; const historyPreviousNextUrlKey = "__vinext_previousNextUrl"; const historyTraversalIndexKey = "__vinext_historyIndex"; + const shallowUrlKey = "__vinext_shallowUrl"; const win = { location: { pathname: "/photo/1", @@ -841,6 +842,7 @@ describe("next/navigation shim", () => { expect(win.history.state).toEqual({ [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, + [shallowUrlKey]: "http://localhost/photo/1?filter=active", myData: { foo: "bar" }, }); @@ -848,24 +850,28 @@ describe("next/navigation shim", () => { expect(win.history.state).toEqual({ [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, + [shallowUrlKey]: "http://localhost/photo/1?filter=pending", }); win.history.replaceState(null, "", "/photo/1?filter=archived"); expect(win.history.state).toEqual({ [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, + [shallowUrlKey]: "http://localhost/photo/1?filter=archived", }); win.history.replaceState(undefined, "", "/photo/1?filter=all"); expect(win.history.state).toEqual({ [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, + [shallowUrlKey]: "http://localhost/photo/1?filter=all", }); win.history.state = { [historyTraversalIndexKey]: 7 }; win.history.pushState({ next: true }, "", "/photo/1?filter=done"); expect(win.history.state).toEqual({ [historyTraversalIndexKey]: 7, + [shallowUrlKey]: "http://localhost/photo/1?filter=done", next: true, }); } finally { From 126007f9e4dce4bbc61da6487918a46972fe3daa Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Sat, 8 Aug 2026 11:26:42 +0530 Subject: [PATCH 2/5] fix(app-router): track shallow history snapshots --- .../vinext/src/client/navigation-runtime.ts | 6 +++ .../vinext/src/server/app-browser-entry.ts | 29 ++++++++---- .../server/app-browser-history-controller.ts | 28 +++++++++++ .../vinext/src/server/app-history-state.ts | 6 ++- packages/vinext/src/shims/navigation.ts | 46 +++++++++++-------- tests/app-browser-history-controller.test.ts | 44 ++++++++++++++++++ tests/e2e/app-router/advanced.spec.ts | 24 ++++++++++ .../app-basic/app/shallow-test/page.tsx | 7 ++- 8 files changed, 159 insertions(+), 31 deletions(-) diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 7580b06567..7ff012ad67 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -55,6 +55,11 @@ export type NavigationRuntimeNavigate = ( export type NavigationRuntimeFunctions = { clearNavigationCaches?: () => void; + commitShallowHistory?: ( + callerState: unknown, + url: string | URL | null | undefined, + historyUpdateMode: NavigationRuntimeHistoryUpdateMode, + ) => boolean; commitHashNavigation?: ( href: string, historyUpdateMode: NavigationRuntimeHistoryUpdateMode, @@ -125,6 +130,7 @@ function isNavigationRuntimeFunctions(value: unknown): value is NavigationRuntim if (!isUnknownRecord(value)) return false; return ( isOptionalRuntimeFunction(Reflect.get(value, "clearNavigationCaches")) && + isOptionalRuntimeFunction(Reflect.get(value, "commitShallowHistory")) && isOptionalRuntimeFunction(Reflect.get(value, "commitHashNavigation")) && isOptionalRuntimeFunction(Reflect.get(value, "navigateExternal")) && isOptionalRuntimeFunction(Reflect.get(value, "navigate")) && diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 2a0644720e..c64cc4efaa 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -126,7 +126,6 @@ import { type OperationLane, } from "./app-browser-state.js"; import { AppBrowserHistoryController } from "./app-browser-history-controller.js"; -import { readHistoryStateShallowUrl } from "./app-history-state.js"; import { createVisitedResponseCacheEntry, deleteVisitedResponseCacheEntry, @@ -2389,6 +2388,26 @@ function bootstrapHydration( // the browser entry share a single App Router capability contract. registerNavigationRuntimeFunctions({ clearNavigationCaches: clearClientNavigationCaches, + commitShallowHistory: (callerState, url, historyUpdateMode) => { + if (!browserNavigationController.hasBrowserRouterState()) { + return false; + } + const href = new URL(url ?? window.location.href, window.location.href).href; + const currentState = browserNavigationController.getBrowserRouterState(); + historyController.commitExternalShallowNavigation({ + callerState, + historyUpdateMode, + href, + snapshotState: { + ...currentState, + navigationSnapshot: createClientNavigationRenderSnapshot( + href, + currentState.navigationSnapshot.params, + ), + }, + }); + return true; + }, commitHashNavigation: (href, historyUpdateMode, scroll) => historyController.commitHashOnlyNavigation(href, historyUpdateMode, scroll), getPrefetchRouterState: () => { @@ -2455,14 +2474,6 @@ function bootstrapHydration( // Notify the transition start so observers still see the URL change, then // restore scroll directly and skip the RSC dispatch. const href = window.location.href; - const shallowUrl = readHistoryStateShallowUrl(event.state); - if (shallowUrl !== null && new URL(shallowUrl, href).href === href) { - notifyAppRouterTransitionStart(href, "traverse"); - historyController.commitTraversalIndexFromHistoryState(event.state); - commitClientNavigationState(); - restorePopstateScrollPosition(event.state); - return; - } if (isSameAppRoutePopstateTarget(href)) { notifyAppRouterTransitionStart(href, "traverse"); historyController.commitTraversalIndexFromHistoryState(event.state); diff --git a/packages/vinext/src/server/app-browser-history-controller.ts b/packages/vinext/src/server/app-browser-history-controller.ts index 92d0154ee8..bcbf076405 100644 --- a/packages/vinext/src/server/app-browser-history-controller.ts +++ b/packages/vinext/src/server/app-browser-history-controller.ts @@ -1,5 +1,6 @@ import { RestorableClientStateController, + createExternalHistoryStatePreservingMetadata, createHistoryStateWithNavigationMetadata, readHistoryStateBfcacheIds, readHistoryStatePreviousNextUrl, @@ -62,6 +63,13 @@ type CommitNavigationHistoryOptions = { stageClientParams: () => void; }; +type CommitExternalShallowNavigationOptions = { + callerState: unknown; + href: string; + historyUpdateMode: HistoryUpdateMode; + snapshotState: AppRouterState; +}; + export function createCanonicalBrowserHistoryHref(href: string): string { const url = new URL(href); return `${url.pathname}${url.search}${url.hash}`; @@ -196,6 +204,26 @@ export class AppBrowserHistoryController { // --- History metadata writes --- + commitExternalShallowNavigation(options: CommitExternalShallowNavigationOptions): void { + const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex( + options.historyUpdateMode, + ); + const historyState = createExternalHistoryStatePreservingMetadata( + options.callerState, + this.#readHistoryState(), + options.href, + navigationHistoryIndex, + ); + + if (options.historyUpdateMode === "replace") { + this.#replaceHistoryState(historyState, options.href); + } else { + this.#pushHistoryState(historyState, options.href); + } + this.commitHistoryTraversalIndex(navigationHistoryIndex); + this.rememberHistoryStateSnapshot(options.snapshotState); + } + commitHashOnlyNavigation( href: string, historyUpdateMode: HistoryUpdateMode, diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index 0fe3f45322..8309550b3b 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -248,9 +248,13 @@ export function createExternalHistoryStatePreservingMetadata( callerState: unknown, currentHistoryState: unknown, shallowUrl?: string, + traversalIndexOverride?: number | null, ): unknown { const previousNextUrl = readHistoryStatePreviousNextUrl(currentHistoryState); - const traversalIndex = readHistoryStateTraversalIndex(currentHistoryState); + const traversalIndex = + traversalIndexOverride === undefined + ? readHistoryStateTraversalIndex(currentHistoryState) + : traversalIndexOverride; const bfcacheIds = readHistoryStateBfcacheIds(currentHistoryState); const bfcacheVersion = readHistoryStateBfcacheVersion(currentHistoryState); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index dbe94e60f8..c3949ed921 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -3025,16 +3025,19 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalPushState.call( - window.history, - createExternalHistoryStatePreservingMetadata( - data, - window.history.state, - new URL(url ?? window.location.href, window.location.href).href, - ), - unused, - url, - ); + const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; + if (!commitShallowHistory?.(data, url, "push")) { + state.originalPushState.call( + window.history, + createExternalHistoryStatePreservingMetadata( + data, + window.history.state, + new URL(url ?? window.location.href, window.location.href).href, + ), + unused, + url, + ); + } if (state.suppressUrlNotifyCount === 0) { // A raw history.pushState (shallow routing) supersedes a pending link, // but changes browser state only — it issues no RSC request, so it must @@ -3049,16 +3052,19 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalReplaceState.call( - window.history, - createExternalHistoryStatePreservingMetadata( - data, - window.history.state, - new URL(url ?? window.location.href, window.location.href).href, - ), - unused, - url, - ); + const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; + if (!commitShallowHistory?.(data, url, "replace")) { + state.originalReplaceState.call( + window.history, + createExternalHistoryStatePreservingMetadata( + data, + window.history.state, + new URL(url ?? window.location.href, window.location.href).href, + ), + unused, + url, + ); + } if (state.suppressUrlNotifyCount === 0) { resetStaleLinkStatus(); commitClientNavigationState(); diff --git a/tests/app-browser-history-controller.test.ts b/tests/app-browser-history-controller.test.ts index 0ccbf65ccd..671602084f 100644 --- a/tests/app-browser-history-controller.test.ts +++ b/tests/app-browser-history-controller.test.ts @@ -315,6 +315,50 @@ describe("AppBrowserHistoryController snapshot restore", () => { controller.rememberHistoryStateSnapshot(snapshotState); } + it("assigns a pushed shallow entry its own restorable snapshot", () => { + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata(null, { + previousNextUrl: null, + traversalIndex: 0, + }), + initialHref: "https://example.com/initial", + }); + const snapshotState = createRouterState({ + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/initial/shallow", + {}, + ), + }); + + controller.commitExternalShallowNavigation({ + callerState: { caller: true }, + href: "https://example.com/initial/shallow", + historyUpdateMode: "push", + snapshotState, + }); + + expect(controller.currentHistoryTraversalIndex).toBe(1); + expect(readWrittenState(store.pushed[0])).toMatchObject({ + __vinext_historyIndex: 1, + __vinext_shallowUrl: "https://example.com/initial/shallow", + caller: true, + }); + + controller.commitHistoryTraversalIndex(2); + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: store.pushed[0]?.state, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(snapshotState); + }); + it("resolves the restorable candidate and delegates visible restoration to the injected callback", () => { const { controller } = createController(); const snapshotState = createRouterState({ diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 2c354aebd3..4cb598408b 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -693,6 +693,30 @@ test.describe("Shallow Routing (history.pushState/replaceState)", () => { await expect(pathname).toHaveText("pathname: /shallow-test/sub", { timeout: 10_000 }); }); + test("restores the shallow entry tree after navigating to another route", async ({ page }) => { + await page.goto(`${BASE}/shallow-test`); + + await page.waitForFunction( + () => typeof (window as any).__VINEXT_RSC_ROOT__ !== "undefined", + null, + { timeout: 10000 }, + ); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await expect(page.locator('[data-testid="pathname"]')).toHaveText( + "pathname: /shallow-test/sub", + ); + + await page.locator('[data-testid="shallow-to-about"]').click(); + await expect(page.locator("h1#app-page")).toHaveText("About"); + + await page.goBack(); + await expect(page.getByRole("heading", { name: "Shallow Routing Test" })).toBeVisible(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText( + "pathname: /shallow-test/sub", + ); + }); + test.fixme("multiple pushState calls update search params correctly", async ({ page }) => { await page.goto(`${BASE}/shallow-test`); diff --git a/tests/fixtures/app-basic/app/shallow-test/page.tsx b/tests/fixtures/app-basic/app/shallow-test/page.tsx index d7bfbfc661..cedc93e15f 100644 --- a/tests/fixtures/app-basic/app/shallow-test/page.tsx +++ b/tests/fixtures/app-basic/app/shallow-test/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; /** * Test page for shallow routing via history.pushState/replaceState. @@ -11,6 +11,7 @@ import { usePathname, useSearchParams } from "next/navigation"; */ export default function ShallowTestPage() { const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); return ( @@ -28,6 +29,10 @@ export default function ShallowTestPage() { Push filter=active + + +

refreshes: {completedRefreshes}

+ + ); +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 686922bcac..6b08babb83 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -789,15 +789,10 @@ describe("next/navigation shim", () => { } }); - it("preserves App Router history metadata when external history calls provide caller state", async () => { - // Matches Next.js' external History API wrapper behavior: - // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx#L114-L127 - // Covered by Next.js shallow-routing tests for object, null, and undefined state: - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts + it("preserves arbitrary caller state when the App Router runtime is unavailable", async () => { const previousWindow = (globalThis as any).window; const historyPreviousNextUrlKey = "__vinext_previousNextUrl"; const historyTraversalIndexKey = "__vinext_historyIndex"; - const shallowUrlKey = "__vinext_shallowUrl"; const win = { location: { pathname: "/photo/1", @@ -812,7 +807,7 @@ describe("next/navigation shim", () => { [historyTraversalIndexKey]: 4, } as unknown, pushState(data: unknown, _unused: string, url?: string | URL | null) { - this.state = data; + this.state = structuredClone(data); if (!url) return; const parsed = new URL(url, win.location.href); win.location.pathname = parsed.pathname; @@ -821,7 +816,7 @@ describe("next/navigation shim", () => { win.location.href = parsed.href; }, replaceState(data: unknown, _unused: string, url?: string | URL | null) { - this.state = data; + this.state = structuredClone(data); if (!url) return; const parsed = new URL(url, win.location.href); win.location.pathname = parsed.pathname; @@ -839,41 +834,22 @@ describe("next/navigation shim", () => { await import("../packages/vinext/src/shims/navigation.js"); win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active"); - expect(win.history.state).toEqual({ - [historyPreviousNextUrlKey]: "/feed", - [historyTraversalIndexKey]: 4, - [shallowUrlKey]: "http://localhost/photo/1?filter=active", - myData: { foo: "bar" }, - }); + expect(win.history.state).toEqual({ myData: { foo: "bar" } }); win.history.pushState(null, "", "/photo/1?filter=pending"); - expect(win.history.state).toEqual({ - [historyPreviousNextUrlKey]: "/feed", - [historyTraversalIndexKey]: 4, - [shallowUrlKey]: "http://localhost/photo/1?filter=pending", - }); + expect(win.history.state).toBeNull(); - win.history.replaceState(null, "", "/photo/1?filter=archived"); - expect(win.history.state).toEqual({ - [historyPreviousNextUrlKey]: "/feed", - [historyTraversalIndexKey]: 4, - [shallowUrlKey]: "http://localhost/photo/1?filter=archived", - }); + win.history.replaceState(42, "", "/photo/1?filter=archived"); + expect(win.history.state).toBe(42); - win.history.replaceState(undefined, "", "/photo/1?filter=all"); - expect(win.history.state).toEqual({ - [historyPreviousNextUrlKey]: "/feed", - [historyTraversalIndexKey]: 4, - [shallowUrlKey]: "http://localhost/photo/1?filter=all", - }); + win.history.replaceState(["filter", "all"], "", "/photo/1?filter=all"); + expect(win.history.state).toEqual(["filter", "all"]); - win.history.state = { [historyTraversalIndexKey]: 7 }; - win.history.pushState({ next: true }, "", "/photo/1?filter=done"); - expect(win.history.state).toEqual({ - [historyTraversalIndexKey]: 7, - [shallowUrlKey]: "http://localhost/photo/1?filter=done", - next: true, - }); + const date = new Date("2026-08-08T00:00:00.000Z"); + win.history.pushState(date, "", "/photo/1?filter=done"); + expect(win.history.state).toEqual(date); + + expect(() => win.history.pushState(() => {}, "", "/photo/1?filter=invalid")).toThrow(); } finally { vi.resetModules(); if (previousWindow === undefined) { From f54ae1f07835b143c2ce1b03263b88fb6095dc31 Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Sat, 8 Aug 2026 23:03:13 +0530 Subject: [PATCH 4/5] fix(app-router): harden shallow history restoration --- .../vinext/src/server/app-browser-entry.ts | 1 + .../server/app-browser-history-controller.ts | 22 +++- .../vinext/src/server/app-history-state.ts | 30 +++++ packages/vinext/src/shims/navigation.ts | 31 +----- tests/app-browser-history-controller.test.ts | 105 +++++++++++++++++- tests/shims.test.ts | 11 +- 6 files changed, 160 insertions(+), 40 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index c64cc4efaa..ee8fb963c6 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -2398,6 +2398,7 @@ function bootstrapHydration( callerState, historyUpdateMode, href, + nativeHistoryUrl: url, snapshotState: { ...currentState, navigationSnapshot: createClientNavigationRenderSnapshot( diff --git a/packages/vinext/src/server/app-browser-history-controller.ts b/packages/vinext/src/server/app-browser-history-controller.ts index 6494a8b587..2d38101932 100644 --- a/packages/vinext/src/server/app-browser-history-controller.ts +++ b/packages/vinext/src/server/app-browser-history-controller.ts @@ -30,9 +30,9 @@ type AppBrowserHistoryControllerDeps = { /** Reads `window.location.href`. Injected so the controller stays unit-testable. */ readCurrentHref: () => string; /** Wraps `pushHistoryStateWithoutNotify(state, "", href)`. */ - pushHistoryState: (state: unknown, href: string) => void; + pushHistoryState: (state: unknown, href?: string | URL | null) => void; /** Wraps `replaceHistoryStateWithoutNotify(state, "", href)`. */ - replaceHistoryState: (state: unknown, href?: string) => void; + replaceHistoryState: (state: unknown, href?: string | URL | null) => void; readVisibleNavigationMetadata: () => VisibleNavigationMetadata | null; }; @@ -67,6 +67,7 @@ type CommitExternalShallowNavigationOptions = { callerState: unknown; href: string; historyUpdateMode: HistoryUpdateMode; + nativeHistoryUrl: string | URL | null | undefined; snapshotState: AppRouterState; }; @@ -108,8 +109,8 @@ export class AppBrowserHistoryController { readonly #restorableClientState: RestorableClientStateController; readonly #readHistoryState: () => unknown; readonly #readCurrentHref: () => string; - readonly #pushHistoryState: (state: unknown, href: string) => void; - readonly #replaceHistoryState: (state: unknown, href?: string) => void; + readonly #pushHistoryState: (state: unknown, href?: string | URL | null) => void; + readonly #replaceHistoryState: (state: unknown, href?: string | URL | null) => void; readonly #readVisibleNavigationMetadata: () => VisibleNavigationMetadata | null; // Highest app-owned traversal index we know about (`#next`) versus the index @@ -205,6 +206,7 @@ export class AppBrowserHistoryController { // --- History metadata writes --- commitExternalShallowNavigation(options: CommitExternalShallowNavigationOptions): void { + const previousHistoryIndex = this.#currentHistoryTraversalIndex; const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex( options.historyUpdateMode, ); @@ -216,9 +218,10 @@ export class AppBrowserHistoryController { ); if (options.historyUpdateMode === "replace") { - this.#replaceHistoryState(historyState, options.href); + this.#replaceHistoryState(historyState, options.nativeHistoryUrl); } else { - this.#pushHistoryState(historyState, options.href); + this.#pushHistoryState(historyState, options.nativeHistoryUrl); + this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex); } this.commitHistoryTraversalIndex(navigationHistoryIndex); this.#restorableClientState.rememberHistoryStateSnapshot({ @@ -233,6 +236,7 @@ export class AppBrowserHistoryController { historyUpdateMode: HistoryUpdateMode, scroll: boolean, ): void { + const previousHistoryIndex = this.#currentHistoryTraversalIndex; const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex(historyUpdateMode); const historyState = this.#readHistoryState(); const visible = this.#readVisibleNavigationMetadata(); @@ -257,6 +261,7 @@ export class AppBrowserHistoryController { this.#replaceHistoryState(nextHistoryState, href); } else { this.#pushHistoryState(nextHistoryState, href); + this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex); } this.commitHistoryTraversalIndex(navigationHistoryIndex); } @@ -280,6 +285,7 @@ export class AppBrowserHistoryController { * into the history entry during the navigation commit. */ commitNavigationHistory(options: CommitNavigationHistoryOptions): void { + const previousHistoryIndex = this.#currentHistoryTraversalIndex; const currentHref = this.#readCurrentHref(); const origin = new URL(currentHref).origin; const targetHref = new URL(options.href, origin).href; @@ -307,6 +313,7 @@ export class AppBrowserHistoryController { } else if (options.historyUpdateMode === "push" && currentHref !== targetHref) { options.stageClientParams(); this.#pushHistoryState(historyState, options.href); + this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex); wroteHistoryState = true; this.commitHistoryTraversalIndex(navigationHistoryIndex); } @@ -320,6 +327,9 @@ export class AppBrowserHistoryController { this.commitHistoryTraversalIndex(options.targetHistoryIndex); } } + if (navigationHistoryIndex !== null) { + this.#restorableClientState.supersedeDurableHistoryStateSnapshot(navigationHistoryIndex); + } } syncCurrentHistoryStatePreviousNextUrl( diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index 19c5a70d61..13108a083d 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -54,6 +54,21 @@ export class HistoryStateSnapshotCache { this.#snapshots.clear(); } + pruneAfter(historyIndex: number | null): void { + if (historyIndex === null) return; + for (const snapshotIndex of this.#snapshots.keys()) { + if (snapshotIndex > historyIndex) this.#snapshots.delete(snapshotIndex); + } + for (const snapshotIndex of this.#durableSnapshots.keys()) { + if (snapshotIndex > historyIndex) this.#durableSnapshots.delete(snapshotIndex); + } + } + + supersedeDurable(historyIndex: number | null): void { + if (historyIndex === null) return; + this.#durableSnapshots.delete(historyIndex); + } + remember(options: { bfcacheVersion: number; durable?: boolean; @@ -68,6 +83,13 @@ export class HistoryStateSnapshotCache { return; } + // Rendering a restored shallow entry observes the same traversal index. + // Refresh its stored tree without changing its durable ownership. + if (options.durable === undefined && this.#durableSnapshots.has(options.historyIndex)) { + this.#durableSnapshots.set(options.historyIndex, options.state); + return; + } + // A normal navigation replacing the same traversal entry supersedes any // shallow restoration state previously associated with that index. this.#durableSnapshots.delete(options.historyIndex); @@ -178,6 +200,14 @@ export class RestorableClientStateController { this.#invalidateBfcacheIds(); } + pruneHistoryStateSnapshotsAfter(historyIndex: number | null): void { + this.#snapshots.pruneAfter(historyIndex); + } + + supersedeDurableHistoryStateSnapshot(historyIndex: number | null): void { + this.#snapshots.supersedeDurable(historyIndex); + } + rememberHistoryStateSnapshot(options: { durable?: boolean; historyIndex: number | null; diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 9e996932d8..eb0be14ef0 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -25,10 +25,7 @@ import { import { INITIAL_BFCACHE_ID, PUBLIC_INITIAL_BFCACHE_ID } from "../server/app-bfcache-id.js"; import { AppElementsWire, type AppElements } from "../server/app-elements.js"; import { resolveManifestNavigationInterceptionContext } from "../server/app-browser-interception-context.js"; -import { - createExternalHistoryStatePreservingMetadata, - createHashOnlyHistoryStatePreservingNavigationMetadata, -} from "../server/app-history-state.js"; +import { createHashOnlyHistoryStatePreservingNavigationMetadata } from "../server/app-history-state.js"; import { createRscRequestHeaders, createRscRequestUrl, @@ -3027,18 +3024,7 @@ if (!isServer) { ): void { const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; if (!commitShallowHistory?.(data, url, "push")) { - state.originalPushState.call( - window.history, - hasAppNavigationRuntime() - ? createExternalHistoryStatePreservingMetadata( - data, - window.history.state, - new URL(url ?? window.location.href, window.location.href).href, - ) - : data, - unused, - url, - ); + state.originalPushState.call(window.history, data, unused, url); } if (state.suppressUrlNotifyCount === 0) { // A raw history.pushState (shallow routing) supersedes a pending link, @@ -3056,18 +3042,7 @@ if (!isServer) { ): void { const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; if (!commitShallowHistory?.(data, url, "replace")) { - state.originalReplaceState.call( - window.history, - hasAppNavigationRuntime() - ? createExternalHistoryStatePreservingMetadata( - data, - window.history.state, - new URL(url ?? window.location.href, window.location.href).href, - ) - : data, - unused, - url, - ); + state.originalReplaceState.call(window.history, data, unused, url); } if (state.suppressUrlNotifyCount === 0) { resetStaleLinkStatus(); diff --git a/tests/app-browser-history-controller.test.ts b/tests/app-browser-history-controller.test.ts index 2241882662..00af3569d1 100644 --- a/tests/app-browser-history-controller.test.ts +++ b/tests/app-browser-history-controller.test.ts @@ -20,7 +20,7 @@ import { import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import type { AppRouterState } from "../packages/vinext/src/server/app-browser-state.js"; -type HistoryWrite = { state: unknown; href?: string }; +type HistoryWrite = { state: unknown; href?: string | URL | null }; function readWrittenState(write: HistoryWrite | undefined): Record { const state = write?.state; @@ -64,15 +64,17 @@ function createHistoryStore(initialState: unknown = null, initialHref = "https:/ setState: (next: unknown) => { state = next; }, - pushHistoryState: (next: unknown, nextHref: string) => { + pushHistoryState: (next: unknown, nextHref?: string | URL | null) => { pushed.push({ state: next, href: nextHref }); state = next; - href = new URL(nextHref, href).href; + if (nextHref != null) { + href = new URL(nextHref, href).href; + } }, - replaceHistoryState: (next: unknown, nextHref?: string) => { + replaceHistoryState: (next: unknown, nextHref?: string | URL | null) => { replaced.push({ state: next, href: nextHref }); state = next; - if (nextHref !== undefined) { + if (nextHref != null) { href = new URL(nextHref, href).href; } }, @@ -335,6 +337,7 @@ describe("AppBrowserHistoryController snapshot restore", () => { callerState: { caller: true }, href: "https://example.com/initial/shallow", historyUpdateMode: "push", + nativeHistoryUrl: "initial/shallow", snapshotState, }); @@ -344,6 +347,7 @@ describe("AppBrowserHistoryController snapshot restore", () => { __vinext_shallowUrl: "https://example.com/initial/shallow", caller: true, }); + expect(store.pushed[0]?.href).toBe("initial/shallow"); controller.commitHistoryTraversalIndex(2); const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { @@ -381,6 +385,7 @@ describe("AppBrowserHistoryController snapshot restore", () => { callerState: null, href: "https://example.com/shallow-test/sub", historyUpdateMode: "push", + nativeHistoryUrl: "/shallow-test/sub", snapshotState: shallowState, }); const shallowHistoryState = store.pushed[0]?.state; @@ -406,6 +411,96 @@ describe("AppBrowserHistoryController snapshot restore", () => { expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(shallowState); }); + it("keeps a restored shallow snapshot durable when the render effect remembers it again", () => { + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata(null, { + previousNextUrl: null, + traversalIndex: 0, + }), + initialHref: "https://example.com/shallow-test", + }); + const shallowState = createRouterState({ routeId: "route:/shallow-test" }); + + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/shallow-test/sub", + historyUpdateMode: "push", + nativeHistoryUrl: "/shallow-test/sub", + snapshotState: shallowState, + }); + const shallowHistoryState = store.pushed[0]?.state; + store.setState(shallowHistoryState); + controller.commitHistoryTraversalIndex(2); + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + + expect( + controller.restoreHistorySnapshot({ + historyState: shallowHistoryState, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + controller.rememberHistoryStateSnapshot(shallowState); + controller.commitHistoryTraversalIndex(2); + controller.invalidateRestorableClientState(); + + expect( + controller.restoreHistorySnapshot({ + historyState: shallowHistoryState, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + }); + + it("prunes durable shallow snapshots from an unreachable forward branch", () => { + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata(null, { + previousNextUrl: null, + traversalIndex: 0, + }), + initialHref: "https://example.com/shallow-test", + }); + const firstState = createRouterState({ routeId: "route:/first" }); + const abandonedState = createRouterState({ routeId: "route:/abandoned" }); + + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/first", + historyUpdateMode: "push", + nativeHistoryUrl: "/first", + snapshotState: firstState, + }); + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/abandoned", + historyUpdateMode: "push", + nativeHistoryUrl: "/abandoned", + snapshotState: abandonedState, + }); + const abandonedHistoryState = store.pushed[1]?.state; + + controller.commitHistoryTraversalIndex(1); + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/replacement", + historyUpdateMode: "push", + nativeHistoryUrl: "/replacement", + snapshotState: createRouterState({ routeId: "route:/replacement" }), + }); + + expect( + controller.restoreHistorySnapshot({ + historyState: abandonedHistoryState, + stageClientParams: vi.fn(), + approveVisibleRestore: vi.fn(() => true), + }), + ).toBe(false); + }); + it("resolves the restorable candidate and delegates visible restoration to the injected callback", () => { const { controller } = createController(); const snapshotState = createRouterState({ diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 6b08babb83..856fc16ab1 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -789,7 +789,7 @@ describe("next/navigation shim", () => { } }); - it("preserves arbitrary caller state when the App Router runtime is unavailable", async () => { + it("preserves arbitrary caller state before App Router state is available", async () => { const previousWindow = (globalThis as any).window; const historyPreviousNextUrlKey = "__vinext_previousNextUrl"; const historyTraversalIndexKey = "__vinext_historyIndex"; @@ -831,6 +831,15 @@ describe("next/navigation shim", () => { try { vi.resetModules(); + const { NAVIGATION_RUNTIME_KEY } = + await import("../packages/vinext/src/client/navigation-runtime.js"); + (win as any)[NAVIGATION_RUNTIME_KEY] = { + bootstrap: { routeManifest: null, rsc: undefined }, + functions: { + commitShallowHistory: () => false, + navigate: vi.fn(async () => {}), + }, + }; await import("../packages/vinext/src/shims/navigation.js"); win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active"); From da5c02fd19d1de1516792c04c45dceb3473f5512 Mon Sep 17 00:00:00 2001 From: Govind Yadav Date: Sun, 9 Aug 2026 12:41:24 +0530 Subject: [PATCH 5/5] fix(app-router): address shallow history review --- .../vinext/src/server/app-browser-entry.ts | 29 +++++++------ .../vinext/src/server/app-history-state.ts | 10 ++++- tests/app-browser-history-controller.test.ts | 43 +++++++++++++++++++ tests/e2e/app-router/advanced.spec.ts | 28 ++++++++++++ .../app-basic/app/shallow-test/page.tsx | 11 +++++ 5 files changed, 107 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index ee8fb963c6..601ab636ea 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -2468,20 +2468,7 @@ function bootstrapHydration( }); window.addEventListener("popstate", (event) => { - // 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. - // Notify the transition start so observers still see the URL change, then - // restore scroll directly and skip the RSC dispatch. const href = window.location.href; - if (isSameAppRoutePopstateTarget(href)) { - notifyAppRouterTransitionStart(href, "traverse"); - historyController.commitTraversalIndexFromHistoryState(event.state); - commitClientNavigationState(); - restorePopstateScrollPosition(event.state); - return; - } const snapshotNavigationId = browserNavigationController.beginNavigation(); if ( restoreHistoryStateSnapshot(event.state, snapshotNavigationId, () => { @@ -2504,6 +2491,22 @@ function bootstrapHydration( browserNavigationController.finalizeNavigation(snapshotNavigationId, null); return; } + + // 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 means this traversal does not need a new RSC + // payload once any entry-specific snapshot has had the first opportunity + // to restore its saved tree. This covers both /page#target -> /page and + // /page -> /page#target. Notify the transition start so observers still see + // the URL change, then restore scroll directly and skip the RSC dispatch. + if (isSameAppRoutePopstateTarget(href)) { + notifyAppRouterTransitionStart(href, "traverse"); + historyController.commitTraversalIndexFromHistoryState(event.state); + commitClientNavigationState(); + restorePopstateScrollPosition(event.state); + browserNavigationController.finalizeNavigation(snapshotNavigationId, null); + return; + } browserNavigationController.finalizeNavigation(snapshotNavigationId, null); handlePopstate(event); }); diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index 13108a083d..c06adb84d4 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -55,7 +55,15 @@ export class HistoryStateSnapshotCache { } pruneAfter(historyIndex: number | null): void { - if (historyIndex === null) return; + if (historyIndex === null) { + // An indexed snapshot belongs to the app-owned branch created after the + // document's metadata-less entries. Pushing from one of those older + // entries discards that whole branch, so none of its snapshots remain + // reachable even though there is no numeric cutoff to compare against. + this.#snapshots.clear(); + this.#durableSnapshots.clear(); + return; + } for (const snapshotIndex of this.#snapshots.keys()) { if (snapshotIndex > historyIndex) this.#snapshots.delete(snapshotIndex); } diff --git a/tests/app-browser-history-controller.test.ts b/tests/app-browser-history-controller.test.ts index 00af3569d1..26b0083e62 100644 --- a/tests/app-browser-history-controller.test.ts +++ b/tests/app-browser-history-controller.test.ts @@ -501,6 +501,49 @@ describe("AppBrowserHistoryController snapshot restore", () => { ).toBe(false); }); + it("prunes the owned snapshot branch when pushing from a metadata-less entry", () => { + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata(null, { + previousNextUrl: null, + traversalIndex: 0, + }), + initialHref: "https://example.com/shallow-test", + }); + + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/first", + historyUpdateMode: "push", + nativeHistoryUrl: "/first", + snapshotState: createRouterState({ routeId: "route:/first" }), + }); + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/abandoned", + historyUpdateMode: "push", + nativeHistoryUrl: "/abandoned", + snapshotState: createRouterState({ routeId: "route:/abandoned" }), + }); + const abandonedHistoryState = store.pushed[1]?.state; + + controller.commitTraversalIndexFromHistoryState(null); + controller.commitExternalShallowNavigation({ + callerState: null, + href: "https://example.com/replacement", + historyUpdateMode: "push", + nativeHistoryUrl: "/replacement", + snapshotState: createRouterState({ routeId: "route:/replacement" }), + }); + + expect( + controller.restoreHistorySnapshot({ + historyState: abandonedHistoryState, + stageClientParams: vi.fn(), + approveVisibleRestore: vi.fn(() => true), + }), + ).toBe(false); + }); + it("resolves the restorable candidate and delegates visible restoration to the injected callback", () => { const { controller } = createController(); const snapshotState = createRouterState({ diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 548e541a34..17d9a2b470 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -717,6 +717,34 @@ test.describe("Shallow Routing (history.pushState/replaceState)", () => { ); }); + test("restores a shallow tree when a multi-entry traversal lands on the current URL", async ({ + page, + }) => { + await page.goto(`${BASE}/shallow-test`); + + await page.waitForFunction( + () => typeof (window as any).__VINEXT_RSC_ROOT__ !== "undefined", + null, + { timeout: 10000 }, + ); + + // Save the shallow-test tree under /about, then render /about normally via + // an intermediate route. history.go(-2) lands on the same visible URL but + // must restore the older entry's shallow-test tree. + await expect(page.locator('[data-testid="pathname"]')).toHaveText("pathname: /shallow-test"); + await page.locator('[data-testid="push-about-path"]').click({ noWaitAfter: true }); + await expect(page.locator('[data-testid="pathname"]')).toHaveText("pathname: /about"); + + await page.locator('[data-testid="shallow-to-home"]').click(); + await expect(page.getByRole("heading", { name: "Welcome to App Router" })).toBeVisible(); + await page.getByRole("link", { name: "Go to About" }).click(); + await expect(page.locator("h1#app-page")).toHaveText("About"); + + await page.evaluate(() => window.history.go(-2)); + await expect(page.getByRole("heading", { name: "Shallow Routing Test" })).toBeVisible(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText("pathname: /about"); + }); + test("restores the shallow entry tree after navigation cache invalidation", async ({ page }) => { await page.goto(`${BASE}/shallow-test`); diff --git a/tests/fixtures/app-basic/app/shallow-test/page.tsx b/tests/fixtures/app-basic/app/shallow-test/page.tsx index cedc93e15f..ee1be57fd6 100644 --- a/tests/fixtures/app-basic/app/shallow-test/page.tsx +++ b/tests/fixtures/app-basic/app/shallow-test/page.tsx @@ -33,6 +33,17 @@ export default function ShallowTestPage() { Go to About + + + +