diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 63cd85d12..d6504f0db 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -77,6 +77,14 @@ export type NavigationRuntimeFunctions = { notifyLinkNavigationStart?: () => void; pingVisibleLinks?: () => void; preparePrefetchResponse?: (response: Response) => Promise; + claimCurrentHistoryTreeSnapshot?: ( + historyUpdateMode: NavigationRuntimeHistoryUpdateMode, + previousHistoryState: unknown, + ) => void; + commitAppOwnedHistoryStateWrite?: ( + historyUpdateMode: NavigationRuntimeHistoryUpdateMode, + previousHistoryState: unknown, + ) => void; }; export type NavigationRuntimeBootstrap = { @@ -132,7 +140,9 @@ function isNavigationRuntimeFunctions(value: unknown): value is NavigationRuntim isOptionalRuntimeFunction(Reflect.get(value, "getPrefetchRouterState")) && isOptionalRuntimeFunction(Reflect.get(value, "notifyLinkNavigationStart")) && isOptionalRuntimeFunction(Reflect.get(value, "pingVisibleLinks")) && - isOptionalRuntimeFunction(Reflect.get(value, "preparePrefetchResponse")) + isOptionalRuntimeFunction(Reflect.get(value, "preparePrefetchResponse")) && + isOptionalRuntimeFunction(Reflect.get(value, "claimCurrentHistoryTreeSnapshot")) && + isOptionalRuntimeFunction(Reflect.get(value, "commitAppOwnedHistoryStateWrite")) ); } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 7bcab31a0..036c86f65 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -119,6 +119,7 @@ import { createInitialBfcacheIdMap, isCacheRestorableAppPayloadMetadata, isCompleteAppPayloadMetadata, + isExternalHistoryState, readHistoryStatePreviousNextUrl, resolveInterceptionContextFromPreviousNextUrl, type AppNavigationPayloadOrigin, @@ -140,6 +141,7 @@ import { import { createPopstateRestoreHandler, restoreSynchronousPopstateScrollPosition, + shouldCommitPopstateUrlWithoutNavigation, } from "./app-browser-popstate.js"; import { DevRecoveryBoundary, @@ -374,14 +376,17 @@ function restoreHistoryStateSnapshot( historyState: unknown, navId: number, onApprovedBeforeCommit?: () => void, + restoreCopiedExternalHistoryEntry = false, ): boolean { let restored = false; flushSync(() => { restored = historyController.restoreHistorySnapshot({ historyState, + preferExternalSnapshot: restoreCopiedExternalHistoryEntry, stageClientParams, approveVisibleRestore: ({ state, beforeCommit }) => browserNavigationController.restoreHistorySnapshotVisibleState({ + restoreCopiedExternalHistoryEntry, beforeCommit: () => { onApprovedBeforeCommit?.(); beforeCommit(); @@ -2458,6 +2463,10 @@ function bootstrapHydration( navigate: navigateRsc, preparePrefetchResponse: (response) => decodeAppElementsPromise(createFromFetch(Promise.resolve(response))), + claimCurrentHistoryTreeSnapshot: (historyUpdateMode, previousHistoryState) => + historyController.claimCurrentHistoryTreeSnapshot(historyUpdateMode, previousHistoryState), + commitAppOwnedHistoryStateWrite: (historyUpdateMode, previousHistoryState) => + historyController.commitAppOwnedHistoryStateWrite(historyUpdateMode, previousHistoryState), }); // Note: This popstate handler runs for App Router (RSC navigation available). @@ -2492,18 +2501,31 @@ 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; - if (isSameAppRoutePopstateTarget(href)) { + const isExternalHistoryEntry = isExternalHistoryState(event.state); + if ( + shouldCommitPopstateUrlWithoutNavigation({ + historyState: event.state, + isCurrentExternalHistoryTree: historyController.isCurrentExternalHistoryTree(event.state), + isSameAppRouteTarget: isSameAppRoutePopstateTarget(href), + }) + ) { notifyAppRouterTransitionStart(href, "traverse"); historyController.commitTraversalIndexFromHistoryState(event.state); + commitClientNavigationState(); restorePopstateScrollPosition(event.state); return; } const snapshotNavigationId = browserNavigationController.beginNavigation(); if ( - restoreHistoryStateSnapshot(event.state, snapshotNavigationId, () => { - abortSupersededNavigation(); - notifyAppRouterTransitionStart(href, "traverse"); - }) + restoreHistoryStateSnapshot( + event.state, + snapshotNavigationId, + () => { + abortSupersededNavigation(); + notifyAppRouterTransitionStart(href, "traverse"); + }, + isExternalHistoryEntry, + ) ) { window.__VINEXT_RSC_PENDING__ = null; restoreSynchronousPopstateScrollPosition( diff --git a/packages/vinext/src/server/app-browser-history-controller.ts b/packages/vinext/src/server/app-browser-history-controller.ts index 92d0154ee..8356d5003 100644 --- a/packages/vinext/src/server/app-browser-history-controller.ts +++ b/packages/vinext/src/server/app-browser-history-controller.ts @@ -1,9 +1,17 @@ import { RestorableClientStateController, + clearHistoryStateTreeSnapshotId, + createAppOwnedHistoryState, + createExternalHistoryStatePreservingMetadata, createHistoryStateWithNavigationMetadata, + createHistoryStateWithTreeSnapshotClaim, + createHistoryStateWithTreeSnapshotId, + isExternalHistoryState, + isHistoryStateTreeSnapshotClaimed, readHistoryStateBfcacheIds, readHistoryStatePreviousNextUrl, readHistoryStateTraversalIndex, + readHistoryStateTreeSnapshotId, resolveHistoryTraversalIntent, type BfcacheIdMap, type HistoryTraversalIntent, @@ -49,6 +57,7 @@ export type RestorableSnapshotCandidate = { type RestoreHistorySnapshotOptions = { historyState: unknown; + preferExternalSnapshot?: boolean; stageClientParams: (params: Record) => void; approveVisibleRestore: (candidate: RestorableSnapshotCandidate) => boolean; }; @@ -98,6 +107,13 @@ function stripVinextScrollState(state: unknown): unknown { */ export class AppBrowserHistoryController { readonly #restorableClientState: RestorableClientStateController; + // Unlike traversal-index snapshots, a tree explicitly copied by raw/hash + // history can remain reachable for the document lifetime. Keep one current + // candidate and promote only ids claimed by those history writes; ordinary + // renders are released when the candidate advances. + readonly #treeSnapshots = new Map(); + readonly #treeSnapshotClaimCounts = new Map(); + readonly #treeSnapshotClaimByHistoryIndex = new Map(); readonly #readHistoryState: () => unknown; readonly #readCurrentHref: () => string; readonly #pushHistoryState: (state: unknown, href: string) => void; @@ -110,6 +126,9 @@ export class AppBrowserHistoryController { // still continue from the highest known app history. #currentHistoryTraversalIndex: number | null; #nextHistoryTraversalIndex: number; + #currentTreeSnapshotId: number | null = null; + #nextTreeSnapshotId = 0; + #treeSnapshotIdPendingFreshState: number | null = null; constructor(deps: AppBrowserHistoryControllerDeps) { this.#readHistoryState = deps.readHistoryState; @@ -185,6 +204,11 @@ export class AppBrowserHistoryController { invalidateRestorableClientState(): void { this.#restorableClientState.invalidateClientState(); + const historyTreeSnapshotId = readHistoryStateTreeSnapshotId(this.#readHistoryState()); + this.#treeSnapshotIdPendingFreshState = + historyTreeSnapshotId !== null && this.#treeSnapshots.has(historyTreeSnapshotId) + ? historyTreeSnapshotId + : null; } rememberHistoryStateSnapshot(state: AppRouterState): void { @@ -192,6 +216,143 @@ export class AppBrowserHistoryController { historyIndex: this.#currentHistoryTraversalIndex, state, }); + const historyTreeSnapshotId = readHistoryStateTreeSnapshotId(this.#readHistoryState()); + // A refresh, revalidation, or HMR render updates the backing state for the + // copied tree that was visible when client caches were invalidated. Reuse + // that identity exactly once so every raw history entry that copied it sees + // fresh server elements. Ordinary renders allocate a distinct identity; + // otherwise same-index replace navigations could overwrite an older copied + // tree that remains reachable in forward history. + const updatesExistingTree = + historyTreeSnapshotId !== null && + this.#treeSnapshots.has(historyTreeSnapshotId) && + (historyTreeSnapshotId === this.#treeSnapshotIdPendingFreshState || + this.#treeSnapshotClaimCounts.has(historyTreeSnapshotId)); + const treeSnapshotId = updatesExistingTree ? historyTreeSnapshotId : this.#nextTreeSnapshotId++; + this.#treeSnapshotIdPendingFreshState = null; + const previousTreeSnapshotId = this.#currentTreeSnapshotId; + this.#currentTreeSnapshotId = treeSnapshotId; + this.#treeSnapshots.set(treeSnapshotId, state); + if ( + previousTreeSnapshotId !== null && + previousTreeSnapshotId !== treeSnapshotId && + !this.#treeSnapshotClaimCounts.has(previousTreeSnapshotId) + ) { + this.#treeSnapshots.delete(previousTreeSnapshotId); + } + if (historyTreeSnapshotId !== treeSnapshotId) { + this.#replaceHistoryState( + createHistoryStateWithTreeSnapshotId(this.#readHistoryState(), treeSnapshotId), + ); + } + } + + isCurrentExternalHistoryTree(historyState: unknown): boolean { + const treeSnapshotId = readHistoryStateTreeSnapshotId(historyState); + return treeSnapshotId !== null && treeSnapshotId === this.#currentTreeSnapshotId; + } + + /** Records the raw/hash history entry that now claims the live tree. */ + claimCurrentHistoryTreeSnapshot( + historyUpdateMode: HistoryUpdateMode, + previousHistoryState: unknown, + ): void { + let historyState = this.#readHistoryState(); + const treeSnapshotId = readHistoryStateTreeSnapshotId(historyState); + if (treeSnapshotId === null || !this.#treeSnapshots.has(treeSnapshotId)) return; + + let historyIndex: number | null; + if (historyUpdateMode === "push") { + this.#releaseForwardTreeSnapshotClaims(); + historyIndex = this.#nextHistoryTraversalIndex + 1; + } else { + historyIndex = + readHistoryStateTraversalIndex(previousHistoryState) ?? this.#currentHistoryTraversalIndex; + } + if (historyIndex === null) return; + + historyState = createHistoryStateWithTreeSnapshotClaim( + createHistoryStateWithNavigationMetadata(historyState, { + previousNextUrl: readHistoryStatePreviousNextUrl(historyState), + traversalIndex: historyIndex, + }), + true, + ); + this.#replaceHistoryState(historyState); + this.#claimTreeSnapshotAtHistoryIndex(historyIndex, treeSnapshotId); + this.commitHistoryTraversalIndex(historyIndex); + } + + /** + * Applies only the claim cleanup caused by a successful raw History API write + * whose caller state is already app-owned. The browser write intentionally + * bypasses external-tree claiming (matching Next.js' `data?.__NA` path), but + * it still overwrites or truncates entries that may own retained snapshots. + * Every cleanup operation is idempotent so duplicate runtime delivery is safe. + */ + commitAppOwnedHistoryStateWrite( + historyUpdateMode: HistoryUpdateMode, + previousHistoryState: unknown, + ): void { + if (historyUpdateMode === "push") { + this.#releaseForwardTreeSnapshotClaims(); + return; + } + + if ( + !isExternalHistoryState(previousHistoryState) && + !isHistoryStateTreeSnapshotClaimed(previousHistoryState) + ) { + return; + } + const previousHistoryIndex = + readHistoryStateTraversalIndex(previousHistoryState) ?? this.#currentHistoryTraversalIndex; + if (previousHistoryIndex !== null) { + this.#releaseTreeSnapshotClaimAtHistoryIndex(previousHistoryIndex); + } + } + + #claimTreeSnapshotAtHistoryIndex(historyIndex: number, treeSnapshotId: number): void { + const previousTreeSnapshotId = this.#treeSnapshotClaimByHistoryIndex.get(historyIndex); + if (previousTreeSnapshotId === treeSnapshotId) return; + if (previousTreeSnapshotId !== undefined) { + this.#releaseTreeSnapshotClaim(previousTreeSnapshotId); + } + this.#treeSnapshotClaimByHistoryIndex.set(historyIndex, treeSnapshotId); + this.#treeSnapshotClaimCounts.set( + treeSnapshotId, + (this.#treeSnapshotClaimCounts.get(treeSnapshotId) ?? 0) + 1, + ); + } + + #releaseTreeSnapshotClaimAtHistoryIndex(historyIndex: number): void { + const treeSnapshotId = this.#treeSnapshotClaimByHistoryIndex.get(historyIndex); + if (treeSnapshotId === undefined) return; + this.#treeSnapshotClaimByHistoryIndex.delete(historyIndex); + this.#releaseTreeSnapshotClaim(treeSnapshotId); + } + + #releaseForwardTreeSnapshotClaims(): void { + const currentHistoryIndex = this.#currentHistoryTraversalIndex; + if (currentHistoryIndex === null) return; + for (const historyIndex of this.#treeSnapshotClaimByHistoryIndex.keys()) { + if (historyIndex > currentHistoryIndex) { + this.#releaseTreeSnapshotClaimAtHistoryIndex(historyIndex); + } + } + } + + #releaseTreeSnapshotClaim(treeSnapshotId: number): void { + const claimCount = this.#treeSnapshotClaimCounts.get(treeSnapshotId); + if (claimCount === undefined) return; + if (claimCount > 1) { + this.#treeSnapshotClaimCounts.set(treeSnapshotId, claimCount - 1); + return; + } + this.#treeSnapshotClaimCounts.delete(treeSnapshotId); + if (treeSnapshotId !== this.#currentTreeSnapshotId) { + this.#treeSnapshots.delete(treeSnapshotId); + } } // --- History metadata writes --- @@ -201,6 +362,9 @@ export class AppBrowserHistoryController { historyUpdateMode: HistoryUpdateMode, scroll: boolean, ): void { + if (historyUpdateMode === "push") { + this.#releaseForwardTreeSnapshotClaims(); + } const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex(historyUpdateMode); const historyState = this.#readHistoryState(); const visible = this.#readVisibleNavigationMetadata(); @@ -210,15 +374,18 @@ export class AppBrowserHistoryController { const bfcacheIds = visible ? visible.bfcacheIds : this.#restorableClientState.readCurrentBfcacheVersionHistoryIds(historyState); - const nextHistoryState = createHistoryStateWithNavigationMetadata( - this.#createHashOnlyNavigationBaseHistoryState(historyUpdateMode, scroll), - { - bfcacheIds, - bfcacheVersion: - bfcacheIds === null ? undefined : this.#restorableClientState.currentBfcacheVersion, - previousNextUrl, - traversalIndex: navigationHistoryIndex, - }, + const nextHistoryState = createHistoryStateWithTreeSnapshotClaim( + createHistoryStateWithNavigationMetadata( + this.#createHashOnlyNavigationBaseHistoryState(historyUpdateMode, scroll), + { + bfcacheIds, + bfcacheVersion: + bfcacheIds === null ? undefined : this.#restorableClientState.currentBfcacheVersion, + previousNextUrl, + traversalIndex: navigationHistoryIndex, + }, + ), + true, ); if (historyUpdateMode === "replace") { @@ -226,6 +393,10 @@ export class AppBrowserHistoryController { } else { this.#pushHistoryState(nextHistoryState, href); } + const treeSnapshotId = readHistoryStateTreeSnapshotId(nextHistoryState); + if (navigationHistoryIndex !== null && treeSnapshotId !== null) { + this.#claimTreeSnapshotAtHistoryIndex(navigationHistoryIndex, treeSnapshotId); + } this.commitHistoryTraversalIndex(navigationHistoryIndex); } @@ -233,10 +404,16 @@ export class AppBrowserHistoryController { historyUpdateMode: HistoryUpdateMode, scroll: boolean, ): unknown { + const historyState = this.#readHistoryState(); if (historyUpdateMode !== "replace") { - return null; + const treeState = createHistoryStateWithTreeSnapshotId( + null, + readHistoryStateTreeSnapshotId(historyState), + ); + return isExternalHistoryState(historyState) + ? createExternalHistoryStatePreservingMetadata(treeState, historyState) + : treeState; } - const historyState = this.#readHistoryState(); return scroll ? stripVinextScrollState(historyState) : historyState; } @@ -249,31 +426,49 @@ export class AppBrowserHistoryController { */ commitNavigationHistory(options: CommitNavigationHistoryOptions): void { const currentHref = this.#readCurrentHref(); + const currentHistoryState = this.#readHistoryState(); const origin = new URL(currentHref).origin; const targetHref = new URL(options.href, origin).href; const preserveExistingState = options.historyUpdateMode === "replace"; + const replacesClaimedOrExternalTree = + preserveExistingState && + (isExternalHistoryState(currentHistoryState) || + isHistoryStateTreeSnapshotClaimed(currentHistoryState)); const navigationHistoryIndex = options.targetHistoryIndex !== undefined ? options.targetHistoryIndex : this.allocateNavigationHistoryTraversalIndex(options.historyUpdateMode); - const historyState = createHistoryStateWithNavigationMetadata( - preserveExistingState ? this.#readHistoryState() : null, - { - bfcacheIds: options.bfcacheIds, - bfcacheVersion: this.#restorableClientState.currentBfcacheVersion, - previousNextUrl: options.previousNextUrl, - traversalIndex: navigationHistoryIndex, - }, + const historyState = clearHistoryStateTreeSnapshotId( + createAppOwnedHistoryState( + createHistoryStateWithNavigationMetadata( + preserveExistingState ? currentHistoryState : null, + { + bfcacheIds: options.bfcacheIds, + bfcacheVersion: this.#restorableClientState.currentBfcacheVersion, + previousNextUrl: options.previousNextUrl, + traversalIndex: navigationHistoryIndex, + }, + ), + ), ); let wroteHistoryState = false; - if (options.historyUpdateMode === "replace" && currentHref !== targetHref) { + if ( + options.historyUpdateMode === "replace" && + (currentHref !== targetHref || replacesClaimedOrExternalTree) + ) { options.stageClientParams(); + const currentHistoryIndex = + readHistoryStateTraversalIndex(currentHistoryState) ?? this.#currentHistoryTraversalIndex; + if (currentHistoryIndex !== null) { + this.#releaseTreeSnapshotClaimAtHistoryIndex(currentHistoryIndex); + } this.#replaceHistoryState(historyState, options.href); wroteHistoryState = true; this.commitHistoryTraversalIndex(navigationHistoryIndex); } else if (options.historyUpdateMode === "push" && currentHref !== targetHref) { options.stageClientParams(); + this.#releaseForwardTreeSnapshotClaims(); this.#pushHistoryState(historyState, options.href); wroteHistoryState = true; this.commitHistoryTraversalIndex(navigationHistoryIndex); @@ -351,10 +546,14 @@ export class AppBrowserHistoryController { /** Initial history write performed before hydration starts. */ writeBootstrapHistoryMetadata(): void { this.#replaceHistoryState( - createHistoryStateWithNavigationMetadata(this.#readHistoryState(), { - previousNextUrl: null, - traversalIndex: this.#currentHistoryTraversalIndex, - }), + clearHistoryStateTreeSnapshotId( + createAppOwnedHistoryState( + createHistoryStateWithNavigationMetadata(this.#readHistoryState(), { + previousNextUrl: null, + traversalIndex: this.#currentHistoryTraversalIndex, + }), + ), + ), createCanonicalBrowserHistoryHref(this.#readCurrentHref()), ); } @@ -365,12 +564,14 @@ export class AppBrowserHistoryController { previousNextUrl: string | null; }): void { this.#replaceHistoryState( - createHistoryStateWithNavigationMetadata(this.#readHistoryState(), { - bfcacheIds: options.bfcacheIds, - bfcacheVersion: this.#restorableClientState.currentBfcacheVersion, - previousNextUrl: options.previousNextUrl, - traversalIndex: this.#currentHistoryTraversalIndex, - }), + createAppOwnedHistoryState( + createHistoryStateWithNavigationMetadata(this.#readHistoryState(), { + bfcacheIds: options.bfcacheIds, + bfcacheVersion: this.#restorableClientState.currentBfcacheVersion, + previousNextUrl: options.previousNextUrl, + traversalIndex: this.#currentHistoryTraversalIndex, + }), + ), ); } @@ -386,11 +587,29 @@ export class AppBrowserHistoryController { * not approved. */ restoreHistorySnapshot(options: RestoreHistorySnapshotOptions): boolean { + const restoreTreeSnapshot = (): boolean => { + const treeSnapshotId = readHistoryStateTreeSnapshotId(options.historyState); + const state = treeSnapshotId === null ? undefined : this.#treeSnapshots.get(treeSnapshotId); + if (!state) return false; + + return options.approveVisibleRestore({ + state, + beforeCommit: () => { + this.commitTraversalIndexFromHistoryState(options.historyState); + options.stageClientParams(state.navigationSnapshot.params); + }, + }); + }; + + if (options.preferExternalSnapshot) { + return restoreTreeSnapshot(); + } + const decision = this.#restorableClientState.resolveHistoryStateSnapshotRestore( options.historyState, ); if (decision.kind === "skip") { - return false; + return restoreTreeSnapshot(); } return options.approveVisibleRestore({ diff --git a/packages/vinext/src/server/app-browser-navigation-controller.ts b/packages/vinext/src/server/app-browser-navigation-controller.ts index e1528a553..a045f92e4 100644 --- a/packages/vinext/src/server/app-browser-navigation-controller.ts +++ b/packages/vinext/src/server/app-browser-navigation-controller.ts @@ -131,6 +131,7 @@ type BrowserNavigationController = { beginPendingBrowserRouterState(): PendingBrowserRouterState; finalizeNavigation(navId: number, pending: PendingBrowserRouterState | null | undefined): void; restoreHistorySnapshotVisibleState(options: { + restoreCopiedExternalHistoryEntry?: boolean; beforeCommit?: () => void; navId: number; state: AppRouterState; @@ -677,15 +678,26 @@ export function createAppBrowserNavigationController( } function restoreHistorySnapshotVisibleState(options: { + restoreCopiedExternalHistoryEntry?: boolean; beforeCommit?: () => void; navId: number; state: AppRouterState; targetHref: string; }): boolean { - if (!isSnapshotTargetHref(basePath, options.state.navigationSnapshot, options.targetHref)) { + if ( + !options.restoreCopiedExternalHistoryEntry && + !isSnapshotTargetHref(basePath, options.state.navigationSnapshot, options.targetHref) + ) { return false; } + // A user pushState entry copies the current App Router tree while exposing a + // different URL to navigation hooks. Approve restoration against the copied + // tree's route, then let commitClientNavigationState publish the browser URL. + const approvalTargetHref = options.restoreCopiedExternalHistoryEntry + ? createSnapshotPathAndSearch(options.state.navigationSnapshot) + : options.targetHref; + const currentState = getBrowserRouterState(); const pending = createRestoredHistorySnapshotCommit({ currentState, @@ -698,7 +710,7 @@ export function createAppBrowserNavigationController( pending, routeManifest: getRouteManifest(), startedNavigationId: options.navId, - targetHref: options.targetHref, + targetHref: approvalTargetHref, }); if (approval.approvedCommit === null) { diff --git a/packages/vinext/src/server/app-browser-popstate.ts b/packages/vinext/src/server/app-browser-popstate.ts index a33b74b39..415fe5890 100644 --- a/packages/vinext/src/server/app-browser-popstate.ts +++ b/packages/vinext/src/server/app-browser-popstate.ts @@ -1,4 +1,5 @@ import { + isExternalHistoryState, readHistoryStateTraversalIndex, type HistoryTraversalIntent, } from "./app-browser-state.js"; @@ -61,6 +62,17 @@ function createPopstateTraversalIntent(historyState: unknown): HistoryTraversalI }; } +export function shouldCommitPopstateUrlWithoutNavigation(options: { + historyState: unknown; + isCurrentExternalHistoryTree: boolean; + isSameAppRouteTarget: boolean; +}): boolean { + if (isExternalHistoryState(options.historyState)) { + return options.isCurrentExternalHistoryTree; + } + return options.isSameAppRouteTarget; +} + export function createPopstateRestoreHandler( deps: BrowserPopstateRestoreDeps, ): (event: PopStateEvent) => void { diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index d6095e962..0084cb988 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -51,8 +51,12 @@ export { } from "./app-bfcache-identity.js"; export { + createAppOwnedHistoryState, + createExternalHistoryStatePreservingMetadata, + createHistoryStateWithTreeSnapshotId, createHistoryStateWithNavigationMetadata, createHistoryStateWithPreviousNextUrl, + isExternalHistoryState, isHistoryStateBfcacheVersionCurrent, readHistoryStateBfcacheIds, readHistoryStateBfcacheVersion, diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index 2c542f40c..2f4d6971c 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -6,6 +6,9 @@ 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_EXTERNAL_HISTORY_STATE_KEY = "__vinext_externalHistoryState"; +const VINEXT_TREE_SNAPSHOT_ID_HISTORY_STATE_KEY = "__vinext_treeSnapshotId"; +const VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY = "__vinext_treeSnapshotClaimed"; type HistoryStateRecord = { [key: string]: unknown; @@ -251,17 +254,92 @@ export function createExternalHistoryStatePreservingMetadata( const traversalIndex = readHistoryStateTraversalIndex(currentHistoryState); const bfcacheIds = readHistoryStateBfcacheIds(currentHistoryState); const bfcacheVersion = readHistoryStateBfcacheVersion(currentHistoryState); + const treeSnapshotId = readHistoryStateTreeSnapshotId(currentHistoryState); - if (previousNextUrl === null && traversalIndex === null && bfcacheIds === null) { - return callerState; - } - - return createHistoryStateWithNavigationMetadata(callerState, { + const state = createHistoryStateWithNavigationMetadata(callerState, { bfcacheIds, bfcacheVersion: bfcacheIds === null ? undefined : bfcacheVersion, previousNextUrl, traversalIndex, }); + // Like Next.js' copied router tree, this identifies an entry whose browser + // URL may differ while its rendered App Router tree remains the copied one. + return { + ...state, + ...createExternalHistoryStateMarker(), + ...(treeSnapshotId === null + ? {} + : { + [VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY]: true, + [VINEXT_TREE_SNAPSHOT_ID_HISTORY_STATE_KEY]: treeSnapshotId, + }), + }; +} + +function createExternalHistoryStateMarker(): Record { + return { [VINEXT_EXTERNAL_HISTORY_STATE_KEY]: true }; +} + +export function isExternalHistoryState(state: unknown): boolean { + return readHistoryStateRecord(state)?.[VINEXT_EXTERNAL_HISTORY_STATE_KEY] === true; +} + +/** + * Mirrors Next.js' `data?.__NA` guard in its patched History API. Passing a + * previously captured App Router history entry back to pushState/replaceState + * is an internal traversal write, not a new shallow-routing entry. App-owned + * entries always carry a traversal index after bootstrap; externally patched + * entries are distinguished by their explicit external marker. + */ +export function isAppOwnedHistoryState(state: unknown): boolean { + return !isExternalHistoryState(state) && readHistoryStateTraversalIndex(state) !== null; +} + +export function createAppOwnedHistoryState(state: unknown): unknown { + const nextState = cloneHistoryState(state); + delete nextState[VINEXT_EXTERNAL_HISTORY_STATE_KEY]; + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function createHistoryStateWithTreeSnapshotId( + state: unknown, + treeSnapshotId: number | null, +): unknown { + const nextState = cloneHistoryState(state); + const previousTreeSnapshotId = readHistoryStateTreeSnapshotId(nextState); + if (isNonNegativeSafeInteger(treeSnapshotId)) { + nextState[VINEXT_TREE_SNAPSHOT_ID_HISTORY_STATE_KEY] = treeSnapshotId; + if (previousTreeSnapshotId !== treeSnapshotId) { + delete nextState[VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY]; + } + } else { + delete nextState[VINEXT_TREE_SNAPSHOT_ID_HISTORY_STATE_KEY]; + delete nextState[VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY]; + } + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function createHistoryStateWithTreeSnapshotClaim(state: unknown, claimed: boolean): unknown { + const nextState = cloneHistoryState(state); + if (claimed && readHistoryStateTreeSnapshotId(nextState) !== null) { + nextState[VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY] = true; + } else { + delete nextState[VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY]; + } + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function isHistoryStateTreeSnapshotClaimed(state: unknown): boolean { + return readHistoryStateRecord(state)?.[VINEXT_TREE_SNAPSHOT_CLAIMED_HISTORY_STATE_KEY] === true; +} + +export function clearHistoryStateTreeSnapshotId(state: unknown): unknown { + return createHistoryStateWithTreeSnapshotId(state, null); +} + +export function readHistoryStateTreeSnapshotId(state: unknown): number | null { + const value = readHistoryStateRecord(state)?.[VINEXT_TREE_SNAPSHOT_ID_HISTORY_STATE_KEY]; + return isNonNegativeSafeInteger(value) ? 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 847e6147b..27021e452 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -28,6 +28,7 @@ import { resolveManifestNavigationInterceptionContext } from "../server/app-brow import { createExternalHistoryStatePreservingMetadata, createHashOnlyHistoryStatePreservingNavigationMetadata, + isAppOwnedHistoryState, } from "../server/app-history-state.js"; import { createRscRequestHeaders, @@ -3223,12 +3224,30 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { + // Match Next.js' `data?.__NA` escape hatch. Reusing a captured internal + // history entry must remain a real traversal target so back/forward can + // fetch it (and follow redirects) instead of treating it as a copied + // shallow tree. + if (isAppOwnedHistoryState(data)) { + const previousHistoryState = window.history.state; + state.originalPushState.call(window.history, data, unused, url); + getNavigationRuntime()?.functions.commitAppOwnedHistoryStateWrite?.( + "push", + previousHistoryState, + ); + return; + } + const previousHistoryState = window.history.state; state.originalPushState.call( window.history, createExternalHistoryStatePreservingMetadata(data, window.history.state), unused, url, ); + getNavigationRuntime()?.functions.claimCurrentHistoryTreeSnapshot?.( + "push", + previousHistoryState, + ); 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 @@ -3243,12 +3262,26 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { + if (isAppOwnedHistoryState(data)) { + const previousHistoryState = window.history.state; + state.originalReplaceState.call(window.history, data, unused, url); + getNavigationRuntime()?.functions.commitAppOwnedHistoryStateWrite?.( + "replace", + previousHistoryState, + ); + return; + } + const previousHistoryState = window.history.state; state.originalReplaceState.call( window.history, createExternalHistoryStatePreservingMetadata(data, window.history.state), unused, url, ); + getNavigationRuntime()?.functions.claimCurrentHistoryTreeSnapshot?.( + "replace", + previousHistoryState, + ); if (state.suppressUrlNotifyCount === 0) { resetStaleLinkStatus(); commitClientNavigationState(); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 419176312..fd32ab0f1 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -42,6 +42,7 @@ import { createVisitedResponseCacheEntry } from "../packages/vinext/src/server/a import { createPopstateRestoreHandler, restoreSynchronousPopstateScrollPosition, + shouldCommitPopstateUrlWithoutNavigation, } from "../packages/vinext/src/server/app-browser-popstate.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER, @@ -95,8 +96,11 @@ import { import * as navigationShim from "../packages/vinext/src/shims/navigation.js"; import { createBfcacheSegmentIdentityMap, + createAppOwnedHistoryState, + createExternalHistoryStatePreservingMetadata, createHistoryStateWithNavigationMetadata, createHistoryStateWithPreviousNextUrl, + createHistoryStateWithTreeSnapshotId, createInitialBfcacheIdMap, createNextBfcacheIdMap, FRESH_APP_NAVIGATION_PAYLOAD_ORIGIN, @@ -105,6 +109,7 @@ import { isCompleteAppPayloadMetadata, isCacheRestorableAppPayloadMetadata, isHistoryStateBfcacheVersionCurrent, + isExternalHistoryState, readHistoryStateBfcacheIds, readHistoryStateBfcacheVersion, readHistoryStatePreviousNextUrl, @@ -5493,6 +5498,55 @@ describe("app browser root-layout hard navigation", () => { }); describe("app browser entry previousNextUrl helpers", () => { + it("marks external history entries while preserving app-owned metadata", () => { + const state = createExternalHistoryStatePreservingMetadata( + { caller: "state" }, + createHistoryStateWithNavigationMetadata( + { __vinext_treeSnapshotId: 9 }, + { + bfcacheIds: { "page:/feed": "_b_1_" }, + bfcacheVersion: 2, + previousNextUrl: "/feed", + traversalIndex: 4, + }, + ), + ); + + expect(state).toEqual({ + __vinext_bfcacheIds: { "page:/feed": "_b_1_" }, + __vinext_bfcacheVersion: 2, + __vinext_externalHistoryState: true, + __vinext_historyIndex: 4, + __vinext_previousNextUrl: "/feed", + __vinext_treeSnapshotClaimed: true, + __vinext_treeSnapshotId: 9, + caller: "state", + }); + expect(isExternalHistoryState(state)).toBe(true); + }); + + it("removes the external marker when an app-owned history entry commits", () => { + expect( + createAppOwnedHistoryState({ + __vinext_externalHistoryState: true, + __vinext_historyIndex: 4, + caller: "state", + }), + ).toEqual({ + __vinext_historyIndex: 4, + caller: "state", + }); + }); + + it("clears stale claim metadata when assigning a different tree snapshot id", () => { + expect( + createHistoryStateWithTreeSnapshotId( + { __vinext_treeSnapshotClaimed: true, __vinext_treeSnapshotId: 9 }, + 10, + ), + ).toEqual({ __vinext_treeSnapshotId: 10 }); + }); + it("stores previousNextUrl alongside existing history state", () => { expect( createHistoryStateWithPreviousNextUrl( @@ -7517,6 +7571,51 @@ describe("app browser entry bfcacheId helpers", () => { }); describe("createPopstateRestoreHandler", () => { + it("commits copied external entries without navigation while they share the visible tree", () => { + expect( + shouldCommitPopstateUrlWithoutNavigation({ + historyState: { + __vinext_externalHistoryState: true, + __vinext_historyIndex: 4, + }, + isCurrentExternalHistoryTree: true, + isSameAppRouteTarget: false, + }), + ).toBe(true); + + expect( + shouldCommitPopstateUrlWithoutNavigation({ + historyState: { + __vinext_externalHistoryState: true, + __vinext_historyIndex: 4, + }, + isCurrentExternalHistoryTree: false, + isSameAppRouteTarget: false, + }), + ).toBe(false); + + expect( + shouldCommitPopstateUrlWithoutNavigation({ + historyState: { + __vinext_externalHistoryState: true, + __vinext_historyIndex: 4, + }, + isCurrentExternalHistoryTree: false, + isSameAppRouteTarget: true, + }), + ).toBe(false); + }); + + it("keeps same-route hash traversals on the no-navigation path", () => { + expect( + shouldCommitPopstateUrlWithoutNavigation({ + historyState: { __vinext_historyIndex: 4 }, + isCurrentExternalHistoryTree: false, + isSameAppRouteTarget: true, + }), + ).toBe(true); + }); + it("guards synchronous popstate scroll retry to the active navigation", () => { const scrollState = { __vinext_scrollY: 10 }; let activeNavigationId = 3; diff --git a/tests/app-browser-history-controller.test.ts b/tests/app-browser-history-controller.test.ts index 0ccbf65cc..8f122fbeb 100644 --- a/tests/app-browser-history-controller.test.ts +++ b/tests/app-browser-history-controller.test.ts @@ -10,7 +10,10 @@ import { } from "../packages/vinext/src/server/app-browser-navigation-controller.js"; import { createHistoryStateWithNavigationMetadata, + createHistoryStateWithTreeSnapshotId, + isExternalHistoryState, readHistoryStateTraversalIndex, + readHistoryStateTreeSnapshotId, } from "../packages/vinext/src/server/app-history-state.js"; import { AppElementsWire, @@ -82,13 +85,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, @@ -230,6 +234,30 @@ describe("AppBrowserHistoryController hash-only navigation", () => { expect(readHistoryStateTraversalIndex(writtenState)).toBe(1); expect(controller.currentHistoryTraversalIndex).toBe(1); }); + + it("retains copied tree identity when pushing a hash from an external entry", () => { + const bfcacheIds = { "page:/shallow-test": "shallow-page" }; + const { controller, store } = createController({ + initialState: createHistoryStateWithNavigationMetadata( + { __vinext_externalHistoryState: true, __vinext_treeSnapshotId: 7 }, + { + bfcacheIds, + bfcacheVersion: 0, + previousNextUrl: null, + traversalIndex: 0, + }, + ), + visibleMetadata: { bfcacheIds, previousNextUrl: null }, + }); + + controller.commitHashOnlyNavigation("/shallow-test/sub#content", "push", true); + + const writtenState = readWrittenState(store.pushed[0]); + expect(writtenState.__vinext_externalHistoryState).toBe(true); + expect(readHistoryStateTreeSnapshotId(writtenState)).toBe(7); + expect(writtenState.__vinext_bfcacheIds).toEqual(bfcacheIds); + expect(readHistoryStateTraversalIndex(writtenState)).toBe(1); + }); }); describe("AppBrowserHistoryController history metadata sync", () => { @@ -419,6 +447,350 @@ describe("AppBrowserHistoryController snapshot restore", () => { expect(restored).toBe(false); expect(approveVisibleRestore).not.toHaveBeenCalled(); }); + + it("restores an external entry by copied tree identity after its traversal index is replaced", () => { + const { controller, setVisibleMetadata, store } = createController(); + const shallowState = createRouterState({ + bfcacheIds: { "page:/shallow-test": "shallow-page" }, + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/shallow-test", + {}, + ), + routeId: "route:/shallow-test", + }); + seedSnapshotAtIndex(controller, 0, shallowState); + const shallowTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(shallowTreeSnapshotId).not.toBeNull(); + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + store.setState(createHistoryStateWithTreeSnapshotId(store.state, null)); + + const replacementState = createRouterState({ + bfcacheIds: { "page:/about": "about-page" }, + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/about", {}), + routeId: "route:/about", + }); + seedSnapshotAtIndex(controller, 0, replacementState); + setVisibleMetadata({ + bfcacheIds: replacementState.bfcacheIds, + previousNextUrl: null, + }); + + const externalHistoryState = createHistoryStateWithTreeSnapshotId( + createHistoryStateWithNavigationMetadata( + { __vinext_externalHistoryState: true }, + { + bfcacheIds: shallowState.bfcacheIds, + bfcacheVersion: 0, + previousNextUrl: null, + traversalIndex: 0, + }, + ), + shallowTreeSnapshotId, + ); + expect(controller.isCurrentExternalHistoryTree(externalHistoryState)).toBe(false); + + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: externalHistoryState, + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(shallowState); + }); + + it("retains reachable external tree snapshots across traversal-cache eviction", () => { + const { controller, store } = createController(); + const externalState = createRouterState({ routeId: "route:/external" }); + seedSnapshotAtIndex(controller, 0, externalState); + const externalTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(externalTreeSnapshotId).not.toBeNull(); + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + controller.rememberHistoryStateSnapshot(externalState); + expect(readHistoryStateTreeSnapshotId(store.state)).toBe(externalTreeSnapshotId); + store.setState(createHistoryStateWithTreeSnapshotId(store.state, null)); + + // More than the 50-entry traversal-cache limit worth of same-entry replace + // renders must not evict a raw pushState entry's exact tree identity. The + // browser gives us no way to prove that external entry unreachable. + for (let render = 1; render <= 52; render += 1) { + seedSnapshotAtIndex( + controller, + 0, + createRouterState({ routeId: `route:/replacement-${render}` }), + ); + } + + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId( + { __vinext_externalHistoryState: true }, + externalTreeSnapshotId, + ), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(externalState); + }); + + it("releases ordinary tree snapshots that no history entry claims", () => { + const { controller, store } = createController(); + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/ordinary-0" })); + const firstTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(firstTreeSnapshotId).not.toBeNull(); + const supersededTreeSnapshotIds = [firstTreeSnapshotId]; + + for (let render = 1; render <= 52; render += 1) { + seedSnapshotAtIndex( + controller, + 0, + createRouterState({ routeId: `route:/ordinary-${render}` }), + ); + if (render < 52) { + supersededTreeSnapshotIds.push(readHistoryStateTreeSnapshotId(store.state)); + } + } + + const approveVisibleRestore = vi.fn(() => true); + for (const treeSnapshotId of supersededTreeSnapshotIds) { + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId(null, treeSnapshotId), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(false); + } + expect(approveVisibleRestore).not.toHaveBeenCalled(); + }); + + it("releases raw replace claims when app replace overwrites the same entry", () => { + const { controller, store } = createController(); + const overwrittenTreeSnapshotIds: Array = []; + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/cycle-0" })); + + for (let render = 1; render <= 52; render += 1) { + overwrittenTreeSnapshotIds.push(readHistoryStateTreeSnapshotId(store.state)); + controller.claimCurrentHistoryTreeSnapshot("replace", store.state); + controller.commitNavigationHistory({ + bfcacheIds: {}, + href: `/cycle-${render}`, + historyUpdateMode: "replace", + previousNextUrl: null, + stageClientParams: vi.fn(), + }); + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: `route:/cycle-${render}` })); + } + + const approveVisibleRestore = vi.fn(() => true); + for (const treeSnapshotId of overwrittenTreeSnapshotIds) { + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId(null, treeSnapshotId), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(false); + } + expect(approveVisibleRestore).not.toHaveBeenCalled(); + }); + + it("releases an external claim overwritten by a captured app-owned replace", () => { + const { controller, store } = createController({ + initialState: { __vinext_historyIndex: 0 }, + }); + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/external" })); + const overwrittenTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(overwrittenTreeSnapshotId).not.toBeNull(); + + const appOwnedState = store.state; + store.setState({ + ...(appOwnedState as Record), + __vinext_externalHistoryState: true, + }); + controller.claimCurrentHistoryTreeSnapshot("replace", appOwnedState); + const overwrittenExternalState = store.state; + + const capturedAppState = { __vinext_historyIndex: 0, captured: true }; + store.setState(capturedAppState); + controller.commitAppOwnedHistoryStateWrite("replace", overwrittenExternalState); + controller.commitAppOwnedHistoryStateWrite("replace", overwrittenExternalState); + expect(store.state).toEqual(capturedAppState); + expect(isExternalHistoryState(store.state)).toBe(false); + + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/replacement" })); + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId( + { __vinext_externalHistoryState: true }, + overwrittenTreeSnapshotId, + ), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore: vi.fn(() => true), + }), + ).toBe(false); + }); + + it("releases claimed snapshots when a push truncates forward history", () => { + const { controller, store } = createController(); + const truncatedTreeSnapshotIds: Array = []; + + for (let render = 0; render <= 52; render += 1) { + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: `route:/source-${render}` })); + const sourceHistoryState = store.state; + const sourceTreeSnapshotId = readHistoryStateTreeSnapshotId(sourceHistoryState); + controller.claimCurrentHistoryTreeSnapshot("push", sourceHistoryState); + + if (render > 0) { + truncatedTreeSnapshotIds.push(sourceTreeSnapshotId); + } + if (render === 52) break; + + store.setState(sourceHistoryState); + controller.commitTraversalIndexFromHistoryState(sourceHistoryState); + controller.commitNavigationHistory({ + bfcacheIds: {}, + href: `/source-${render + 1}`, + historyUpdateMode: "replace", + previousNextUrl: null, + stageClientParams: vi.fn(), + }); + } + + const approveVisibleRestore = vi.fn(() => true); + for (const treeSnapshotId of truncatedTreeSnapshotIds.slice(0, -1)) { + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId(null, treeSnapshotId), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(false); + } + expect(approveVisibleRestore).not.toHaveBeenCalled(); + }); + + it("releases forward claims truncated by a captured app-owned push after Back", () => { + const { controller, store } = createController({ + initialState: { __vinext_historyIndex: 0 }, + }); + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/source" })); + const sourceHistoryState = store.state; + const truncatedTreeSnapshotId = readHistoryStateTreeSnapshotId(sourceHistoryState); + expect(truncatedTreeSnapshotId).not.toBeNull(); + + controller.claimCurrentHistoryTreeSnapshot("push", sourceHistoryState); + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + store.setState(sourceHistoryState); + controller.commitTraversalIndexFromHistoryState(sourceHistoryState); + + const capturedAppState = { __vinext_historyIndex: 0, captured: true }; + store.setState(capturedAppState); + controller.commitAppOwnedHistoryStateWrite("push", sourceHistoryState); + controller.commitAppOwnedHistoryStateWrite("push", sourceHistoryState); + expect(store.state).toEqual(capturedAppState); + expect(isExternalHistoryState(store.state)).toBe(false); + + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/replacement" })); + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId( + { __vinext_externalHistoryState: true }, + truncatedTreeSnapshotId, + ), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore: vi.fn(() => true), + }), + ).toBe(false); + }); + + it("detaches a same-URL app replace from a tree claimed by another entry", () => { + const { controller, store } = createController({ initialHref: "https://example.com/about" }); + const copiedState = createRouterState({ routeId: "route:/copied" }); + seedSnapshotAtIndex(controller, 0, copiedState); + const copiedTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(copiedTreeSnapshotId).not.toBeNull(); + + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + controller.commitNavigationHistory({ + bfcacheIds: {}, + href: "/about", + historyUpdateMode: "replace", + previousNextUrl: null, + stageClientParams: vi.fn(), + }); + expect(readHistoryStateTreeSnapshotId(store.state)).toBeNull(); + + seedSnapshotAtIndex(controller, 0, createRouterState({ routeId: "route:/about" })); + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId( + { __vinext_externalHistoryState: true, __vinext_treeSnapshotClaimed: true }, + copiedTreeSnapshotId, + ), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(copiedState); + }); + + it("refreshes reachable external tree snapshots across client-cache invalidation", () => { + const { controller, store } = createController(); + const externalState = createRouterState({ routeId: "route:/external" }); + seedSnapshotAtIndex(controller, 0, externalState); + const externalTreeSnapshotId = readHistoryStateTreeSnapshotId(store.state); + expect(externalTreeSnapshotId).not.toBeNull(); + controller.claimCurrentHistoryTreeSnapshot("push", store.state); + + // router.refresh() invalidates BFCache ids and traversal-index snapshots, + // but a forward raw pushState entry still owns this exact copied tree. The + // refresh commit updates the state behind that stable identity rather than + // replaying stale pre-refresh server elements. + controller.invalidateRestorableClientState(); + const refreshedState = createRouterState({ routeId: "route:/external-refreshed" }); + seedSnapshotAtIndex(controller, 0, refreshedState); + expect(readHistoryStateTreeSnapshotId(store.state)).toBe(externalTreeSnapshotId); + + const approveVisibleRestore = vi.fn((candidate: RestorableSnapshotCandidate) => { + candidate.beforeCommit(); + return true; + }); + expect( + controller.restoreHistorySnapshot({ + historyState: createHistoryStateWithTreeSnapshotId( + { __vinext_externalHistoryState: true }, + externalTreeSnapshotId, + ), + preferExternalSnapshot: true, + stageClientParams: vi.fn(), + approveVisibleRestore, + }), + ).toBe(true); + expect(approveVisibleRestore.mock.calls[0]?.[0].state).toBe(refreshedState); + }); }); describe("history snapshot target normalization shared with same-route popstate matching", () => { diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index f47b6abb2..6573d0363 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -669,6 +669,191 @@ test.describe("Shallow Routing (history.pushState/replaceState)", () => { ); }); + // Ported from Next.js: + // test/e2e/app-dir/shallow-routing/shallow-routing.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts + test("pushState pathname is restored across back and forward", async ({ page }) => { + await page.goto(`${BASE}/shallow-test`); + await waitForAppRouterHydration(page); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await expect(page.locator('[data-testid="pathname"]')).toHaveText( + "pathname: /shallow-test/sub", + ); + + await page.goBack(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText("pathname: /shallow-test"); + + await page.goForward(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText( + "pathname: /shallow-test/sub", + ); + }); + + test("pushState entry restores its copied tree after a later app navigation", async ({ + page, + }) => { + await page.goto(`${BASE}/shallow-test`); + await waitForAppRouterHydration(page); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.push("/about"); + }); + await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); + + 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", + ); + + await page.goForward(); + await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); + 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("pushState entry survives a same-index app replace", async ({ page }) => { + await page.goto(`${BASE}/shallow-test`); + await waitForAppRouterHydration(page); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await page.goBack(); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.replace("/about"); + }); + await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); + + await page.goForward(); + await expect(page.getByRole("heading", { name: "Shallow Routing Test" })).toBeVisible(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText( + "pathname: /shallow-test/sub", + ); + }); + + test("same-URL app replace detaches one shared pushState tree", async ({ page }) => { + await page.goto(`${BASE}/shallow-test`); + await waitForAppRouterHydration(page); + + await page.evaluate(() => { + window.history.pushState(null, "", "/external-one"); + window.history.pushState(null, "", "/about"); + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.replace("/about"); + }); + await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(/\/external-one$/); + await expect(page.getByRole("heading", { name: "Shallow Routing Test" })).toBeVisible(); + await expect(page.locator('[data-testid="pathname"]')).toHaveText("pathname: /external-one"); + }); + + test("hash navigation retains a pushState entry's copied tree", async ({ page }) => { + await page.goto(`${BASE}/shallow-test`); + await waitForAppRouterHydration(page); + + await page.locator('[data-testid="push-path"]').click({ noWaitAfter: true }); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.push("#content"); + }); + await expect(page).toHaveURL(/\/shallow-test\/sub#content$/); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.push("/about"); + }); + await expect(page.getByRole("heading", { name: "About" })).toBeVisible(); + + 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("pushState restores the exact copied tree when BFCache identities are reused", async ({ + page, + }) => { + await page.goto(`${BASE}/search?q=one`); + await waitForAppRouterHydration(page); + await expect(page.locator("#search-result")).toHaveText("Results for: one"); + + await page.evaluate(() => { + window.history.pushState(null, "", "/external?q=ext"); + }); + await page.goBack(); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.replace("/search?q=two"); + }); + await expect(page.locator("#search-result")).toHaveText("Results for: two"); + + await page.goForward(); + await expect(page).toHaveURL(/\/external\?q=ext$/); + await expect(page.locator("#search-result")).toHaveText("Results for: one"); + }); + + test("same-URL forward restores a different external copied tree", async ({ page }) => { + await page.goto(`${BASE}/search?q=one`); + await waitForAppRouterHydration(page); + await expect(page.locator("#search-result")).toHaveText("Results for: one"); + + await page.evaluate(() => { + window.history.pushState(null, "", "/search?q=two"); + }); + await page.goBack(); + await page.evaluate(() => { + const router = window.next?.router; + if (!router) throw new Error("window.next.router is not installed"); + void router.replace("/search?q=two"); + }); + await expect(page.locator("#search-result")).toHaveText("Results for: two"); + + await page.goForward(); + await expect(page).toHaveURL(/\/search\?q=two$/); + await expect(page.locator("#search-result")).toHaveText("Results for: one"); + }); + + test("pushState entry keeps its copied tree across router refresh", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/refresh-test`); + await waitForAppRouterHydration(page); + const initialTime = await page.locator("#time").textContent(); + expect(initialTime).toBeTruthy(); + + await page.evaluate(() => { + window.history.pushState(null, "", "/external-refresh-copy"); + }); + await page.goBack(); + + await page.evaluate(() => { + const router = window.next?.router; + if (!router || !("refresh" in router)) { + throw new Error("window.next App Router is not installed"); + } + router.refresh(); + }); + await expect(page.locator("#time")).not.toHaveText(initialTime!); + const refreshedTime = await page.locator("#time").textContent(); + expect(refreshedTime).toBeTruthy(); + + await page.goForward(); + await expect(page).toHaveURL(/\/external-refresh-copy$/); + await expect(page.locator("#time")).toHaveText(refreshedTime!); + }); + 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 4b9f855e7..9339fc9b1 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -812,6 +812,7 @@ describe("next/navigation shim", () => { // 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 const previousWindow = (globalThis as any).window; + const externalHistoryStateKey = "__vinext_externalHistoryState"; const historyPreviousNextUrlKey = "__vinext_previousNextUrl"; const historyTraversalIndexKey = "__vinext_historyIndex"; const win = { @@ -852,10 +853,19 @@ describe("next/navigation shim", () => { try { vi.resetModules(); + const { registerNavigationRuntimeFunctions } = + await import("../packages/vinext/src/client/navigation-runtime.js"); + const claimCurrentHistoryTreeSnapshot = vi.fn(); + const commitAppOwnedHistoryStateWrite = vi.fn(); + registerNavigationRuntimeFunctions({ + claimCurrentHistoryTreeSnapshot, + commitAppOwnedHistoryStateWrite, + }); await import("../packages/vinext/src/shims/navigation.js"); win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active"); expect(win.history.state).toEqual({ + [externalHistoryStateKey]: true, [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, myData: { foo: "bar" }, @@ -863,18 +873,21 @@ describe("next/navigation shim", () => { win.history.pushState(null, "", "/photo/1?filter=pending"); expect(win.history.state).toEqual({ + [externalHistoryStateKey]: true, [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, }); win.history.replaceState(null, "", "/photo/1?filter=archived"); expect(win.history.state).toEqual({ + [externalHistoryStateKey]: true, [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, }); win.history.replaceState(undefined, "", "/photo/1?filter=all"); expect(win.history.state).toEqual({ + [externalHistoryStateKey]: true, [historyPreviousNextUrlKey]: "/feed", [historyTraversalIndexKey]: 4, }); @@ -882,9 +895,39 @@ describe("next/navigation shim", () => { win.history.state = { [historyTraversalIndexKey]: 7 }; win.history.pushState({ next: true }, "", "/photo/1?filter=done"); expect(win.history.state).toEqual({ + [externalHistoryStateKey]: true, [historyTraversalIndexKey]: 7, next: true, }); + expect(claimCurrentHistoryTreeSnapshot).toHaveBeenCalledTimes(5); + + // Next.js bypasses its external History API wrapper when caller data is + // a captured App Router entry (`data?.__NA`). Vinext's traversal index is + // the equivalent ownership signal. This keeps the entry eligible for an + // RSC traversal (including redirects) instead of restoring a copied tree. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx#L331-L370 + const capturedAppState = { + [historyTraversalIndexKey]: 3, + captured: true, + }; + win.history.pushState(capturedAppState, "", "/redirect-target"); + expect(win.history.state).toEqual(capturedAppState); + win.history.replaceState(capturedAppState, "", "/replacement-target"); + expect(win.history.state).toEqual(capturedAppState); + expect(claimCurrentHistoryTreeSnapshot).toHaveBeenCalledTimes(5); + expect(commitAppOwnedHistoryStateWrite).toHaveBeenNthCalledWith( + 1, + "push", + expect.objectContaining({ + [externalHistoryStateKey]: true, + [historyTraversalIndexKey]: 7, + }), + ); + expect(commitAppOwnedHistoryStateWrite).toHaveBeenNthCalledWith( + 2, + "replace", + capturedAppState, + ); } finally { vi.resetModules(); if (previousWindow === undefined) {