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 f5be6d0cd6..ee8fb963c6 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -2388,6 +2388,27 @@ 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, + nativeHistoryUrl: url, + snapshotState: { + ...currentState, + navigationSnapshot: createClientNavigationRenderSnapshot( + href, + currentState.navigationSnapshot.params, + ), + }, + }); + return true; + }, commitHashNavigation: (href, historyUpdateMode, scroll) => historyController.commitHashOnlyNavigation(href, historyUpdateMode, scroll), getPrefetchRouterState: () => { @@ -2457,6 +2478,7 @@ function bootstrapHydration( if (isSameAppRoutePopstateTarget(href)) { notifyAppRouterTransitionStart(href, "traverse"); historyController.commitTraversalIndexFromHistoryState(event.state); + commitClientNavigationState(); restorePopstateScrollPosition(event.state); return; } diff --git a/packages/vinext/src/server/app-browser-history-controller.ts b/packages/vinext/src/server/app-browser-history-controller.ts index 92d0154ee8..2d38101932 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, @@ -29,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; }; @@ -62,6 +63,14 @@ type CommitNavigationHistoryOptions = { stageClientParams: () => void; }; +type CommitExternalShallowNavigationOptions = { + callerState: unknown; + href: string; + historyUpdateMode: HistoryUpdateMode; + nativeHistoryUrl: string | URL | null | undefined; + snapshotState: AppRouterState; +}; + export function createCanonicalBrowserHistoryHref(href: string): string { const url = new URL(href); return `${url.pathname}${url.search}${url.hash}`; @@ -100,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 @@ -196,11 +205,38 @@ export class AppBrowserHistoryController { // --- History metadata writes --- + commitExternalShallowNavigation(options: CommitExternalShallowNavigationOptions): void { + const previousHistoryIndex = this.#currentHistoryTraversalIndex; + const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex( + options.historyUpdateMode, + ); + const historyState = createExternalHistoryStatePreservingMetadata( + options.callerState, + this.#readHistoryState(), + options.href, + navigationHistoryIndex, + ); + + if (options.historyUpdateMode === "replace") { + this.#replaceHistoryState(historyState, options.nativeHistoryUrl); + } else { + this.#pushHistoryState(historyState, options.nativeHistoryUrl); + this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex); + } + this.commitHistoryTraversalIndex(navigationHistoryIndex); + this.#restorableClientState.rememberHistoryStateSnapshot({ + durable: true, + historyIndex: this.#currentHistoryTraversalIndex, + state: options.snapshotState, + }); + } + commitHashOnlyNavigation( href: string, historyUpdateMode: HistoryUpdateMode, scroll: boolean, ): void { + const previousHistoryIndex = this.#currentHistoryTraversalIndex; const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex(historyUpdateMode); const historyState = this.#readHistoryState(); const visible = this.#readVisibleNavigationMetadata(); @@ -225,6 +261,7 @@ export class AppBrowserHistoryController { this.#replaceHistoryState(nextHistoryState, href); } else { this.#pushHistoryState(nextHistoryState, href); + this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex); } this.commitHistoryTraversalIndex(navigationHistoryIndex); } @@ -248,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; @@ -275,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); } @@ -288,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 2c542f40ce..13108a083d 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; @@ -39,6 +40,11 @@ type HistoryStateSnapshotRestoreDecision = export class HistoryStateSnapshotCache { readonly #maxEntries: number; readonly #snapshots = new Map>(); + // External shallow entries can point at a pathname that has no matching app + // route. Their rendered tree is therefore the only in-memory restoration + // source and must survive both general cache invalidation and eviction from + // the bounded navigation snapshot cache. + readonly #durableSnapshots = new Map(); constructor(options: { maxEntries: number }) { this.#maxEntries = options.maxEntries; @@ -48,9 +54,46 @@ export class HistoryStateSnapshotCache { this.#snapshots.clear(); } - remember(options: { bfcacheVersion: number; historyIndex: number | null; state: TState }): void { + 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; + historyIndex: number | null; + state: TState; + }): void { if (options.historyIndex === null) return; + if (options.durable === true) { + this.#snapshots.delete(options.historyIndex); + this.#durableSnapshots.set(options.historyIndex, options.state); + 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); + this.#snapshots.delete(options.historyIndex); this.#snapshots.set(options.historyIndex, { bfcacheVersion: options.bfcacheVersion, @@ -75,13 +118,22 @@ export class HistoryStateSnapshotCache { return { kind: "skip", reason: "missing-history-index", targetHistoryIndex }; } + if (options.guarded) { + return { kind: "skip", reason: "guarded", targetHistoryIndex }; + } + + if (this.#durableSnapshots.has(targetHistoryIndex)) { + return { + kind: "restore", + state: this.#durableSnapshots.get(targetHistoryIndex)!, + targetHistoryIndex, + }; + } + const snapshot = this.#snapshots.get(targetHistoryIndex); if (!snapshot) { return { kind: "skip", reason: "missing-snapshot", targetHistoryIndex }; } - if (options.guarded) { - return { kind: "skip", reason: "guarded", targetHistoryIndex }; - } if (snapshot.bfcacheVersion !== options.currentBfcacheVersion) { this.#snapshots.delete(targetHistoryIndex); return { kind: "skip", reason: "stale-bfcache-version", targetHistoryIndex }; @@ -148,9 +200,22 @@ export class RestorableClientStateController { this.#invalidateBfcacheIds(); } - rememberHistoryStateSnapshot(options: { historyIndex: number | null; state: TState }): void { + 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; + state: TState; + }): void { this.#snapshots.remember({ bfcacheVersion: this.#currentBfcacheVersion, + durable: options.durable, historyIndex: options.historyIndex, state: options.state, }); @@ -246,22 +311,42 @@ export function createHistoryStateWithNavigationMetadata( 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); - 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..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, @@ -3025,12 +3022,10 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalPushState.call( - window.history, - createExternalHistoryStatePreservingMetadata(data, window.history.state), - unused, - url, - ); + const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; + if (!commitShallowHistory?.(data, url, "push")) { + state.originalPushState.call(window.history, data, 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 @@ -3045,12 +3040,10 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalReplaceState.call( - window.history, - createExternalHistoryStatePreservingMetadata(data, window.history.state), - unused, - url, - ); + const commitShallowHistory = getNavigationRuntime()?.functions.commitShallowHistory; + if (!commitShallowHistory?.(data, url, "replace")) { + state.originalReplaceState.call(window.history, data, 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..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; } }, @@ -82,13 +84,14 @@ function createHistoryStore(initialState: unknown = null, initialHref = "https:/ function createController(options?: { initialState?: unknown; initialHref?: string; + maxHistoryStateSnapshots?: number; visibleMetadata?: VisibleNavigationMetadata | null; }) { const store = createHistoryStore(options?.initialState ?? null, options?.initialHref); let visibleMetadata = options?.visibleMetadata ?? null; const controller = new AppBrowserHistoryController({ initialHistoryState: store.state, - maxHistoryStateSnapshots: 50, + maxHistoryStateSnapshots: options?.maxHistoryStateSnapshots ?? 50, readHistoryState: store.readHistoryState, readCurrentHref: store.readCurrentHref, pushHistoryState: store.pushHistoryState, @@ -315,6 +318,189 @@ 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", + nativeHistoryUrl: "initial/shallow", + snapshotState, + }); + + expect(controller.currentHistoryTraversalIndex).toBe(1); + expect(readWrittenState(store.pushed[0])).toMatchObject({ + __vinext_historyIndex: 1, + __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) => { + 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("keeps a shallow snapshot restorable across cache invalidation and bounded-cache eviction", () => { + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata(null, { + previousNextUrl: null, + traversalIndex: 0, + }), + initialHref: "https://example.com/shallow-test", + maxHistoryStateSnapshots: 1, + }); + const shallowState = createRouterState({ + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/shallow-test/sub", + {}, + ), + 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; + + controller.commitHistoryTraversalIndex(2); + controller.rememberHistoryStateSnapshot(createRouterState({ routeId: "route:/about" })); + controller.commitHistoryTraversalIndex(3); + controller.rememberHistoryStateSnapshot(createRouterState({ routeId: "route:/contact" })); + controller.invalidateRestorableClientState(); + controller.commitHistoryTraversalIndex(3); + + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: shallowHistoryState, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + 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/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index f47b6abb26..548e541a34 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -669,6 +669,80 @@ 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("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("restores the shallow entry tree after navigation cache invalidation", 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.locator('[data-testid="about-refresh"]').click(); + await expect(page.locator('[data-testid="about-refresh-count"]')).toHaveText("refreshes: 1"); + + 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/about/page.tsx b/tests/fixtures/app-basic/app/about/page.tsx index 456cf39f5c..9baad21e3d 100644 --- a/tests/fixtures/app-basic/app/about/page.tsx +++ b/tests/fixtures/app-basic/app/about/page.tsx @@ -1,10 +1,12 @@ import Link from "next/link"; +import { AboutRefreshButton } from "./refresh-button"; export default function AboutPage() { return (

About

This is the about page.

+ Back to Home
); diff --git a/tests/fixtures/app-basic/app/about/refresh-button.tsx b/tests/fixtures/app-basic/app/about/refresh-button.tsx new file mode 100644 index 0000000000..78e31fd562 --- /dev/null +++ b/tests/fixtures/app-basic/app/about/refresh-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +export function AboutRefreshButton() { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [completedRefreshes, setCompletedRefreshes] = useState(0); + const refreshStarted = useRef(false); + + useEffect(() => { + if (isPending || !refreshStarted.current) return; + refreshStarted.current = false; + setCompletedRefreshes((count) => count + 1); + }, [isPending]); + + return ( + <> + +

refreshes: {completedRefreshes}

+ + ); +} 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 + +