Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/vinext/src/client/navigation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")) &&
Expand Down
21 changes: 21 additions & 0 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2388,6 +2388,26 @@ function bootstrapHydration(
// the browser entry share a single App Router capability contract.
registerNavigationRuntimeFunctions({
clearNavigationCaches: clearClientNavigationCaches,
commitShallowHistory: (callerState, url, historyUpdateMode) => {
if (!browserNavigationController.hasBrowserRouterState()) {
return false;
}
const href = new URL(url ?? window.location.href, window.location.href).href;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve relative URLs for native shallow history writes

On a Chromium document loaded with URL userinfo, such as http://user:pass@host/, window.location.href strips the userinfo while document.URL retains it (the existing #2614 hydration regression covers this). Resolving a caller's relative pushState/replaceState URL against location.href and forwarding this credential-free absolute href to the controller makes the native history operation throw SecurityError, whereas passing the caller's relative URL succeeds. Use the absolute URL for the navigation snapshot only, while preserving the original URL for the native history write.

Useful? React with 👍 / 👎.

const currentState = browserNavigationController.getBrowserRouterState();
historyController.commitExternalShallowNavigation({
callerState,
historyUpdateMode,
href,
snapshotState: {
...currentState,
navigationSnapshot: createClientNavigationRenderSnapshot(
href,
currentState.navigationSnapshot.params,
),
},
});
return true;
},
commitHashNavigation: (href, historyUpdateMode, scroll) =>
historyController.commitHashOnlyNavigation(href, historyUpdateMode, scroll),
getPrefetchRouterState: () => {
Expand Down Expand Up @@ -2457,6 +2477,7 @@ function bootstrapHydration(
if (isSameAppRoutePopstateTarget(href)) {
notifyAppRouterTransitionStart(href, "traverse");
historyController.commitTraversalIndexFromHistoryState(event.state);
commitClientNavigationState();
restorePopstateScrollPosition(event.state);
return;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/vinext/src/server/app-browser-history-controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
RestorableClientStateController,
createExternalHistoryStatePreservingMetadata,
createHistoryStateWithNavigationMetadata,
readHistoryStateBfcacheIds,
readHistoryStatePreviousNextUrl,
Expand Down Expand Up @@ -62,6 +63,13 @@ type CommitNavigationHistoryOptions = {
stageClientParams: () => void;
};

type CommitExternalShallowNavigationOptions = {
callerState: unknown;
href: string;
historyUpdateMode: HistoryUpdateMode;
snapshotState: AppRouterState;
};

export function createCanonicalBrowserHistoryHref(href: string): string {
const url = new URL(href);
return `${url.pathname}${url.search}${url.hash}`;
Expand Down Expand Up @@ -196,6 +204,30 @@ export class AppBrowserHistoryController {

// --- History metadata writes ---

commitExternalShallowNavigation(options: CommitExternalShallowNavigationOptions): void {
const navigationHistoryIndex = this.allocateNavigationHistoryTraversalIndex(
options.historyUpdateMode,
);
const historyState = createExternalHistoryStatePreservingMetadata(
options.callerState,
this.#readHistoryState(),
options.href,
navigationHistoryIndex,
);

if (options.historyUpdateMode === "replace") {
this.#replaceHistoryState(historyState, options.href);
} else {
this.#pushHistoryState(historyState, options.href);
}
this.commitHistoryTraversalIndex(navigationHistoryIndex);
this.#restorableClientState.rememberHistoryStateSnapshot({
durable: true,
historyIndex: this.#currentHistoryTraversalIndex,
state: options.snapshotState,
});
}

commitHashOnlyNavigation(
href: string,
historyUpdateMode: HistoryUpdateMode,
Expand Down
81 changes: 68 additions & 13 deletions packages/vinext/src/server/app-history-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,6 +40,11 @@ type HistoryStateSnapshotRestoreDecision<TState> =
export class HistoryStateSnapshotCache<TState> {
readonly #maxEntries: number;
readonly #snapshots = new Map<number, HistoryStateSnapshot<TState>>();
// 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<number, TState>();

constructor(options: { maxEntries: number }) {
this.#maxEntries = options.maxEntries;
Expand All @@ -48,9 +54,24 @@ export class HistoryStateSnapshotCache<TState> {
this.#snapshots.clear();
}

remember(options: { bfcacheVersion: number; historyIndex: number | null; state: TState }): void {
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);
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound retention of durable history snapshots

Every hydrated external history.pushState() adds a complete AppRouterState to this map, but maxEntries and clear() apply only to #snapshots; durable entries are never evicted unless the same traversal index is later remembered normally. Repeated shallow pushes therefore retain entire rendered trees for the document lifetime, including entries made unreachable when the user goes Back and pushes a new history branch. Add explicit pruning or bounded ownership for durable snapshots while preserving entries that remain traversable.

AGENTS.md reference: AGENTS.md:L477-L483

Useful? React with 👍 / 👎.

return;
}

// A normal navigation replacing the same traversal entry supersedes any
// shallow restoration state previously associated with that index.
this.#durableSnapshots.delete(options.historyIndex);
Comment on lines +71 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep shallow snapshots durable after their first restoration

Fresh evidence after the prior cache-invalidation fix: when Back restores a durable shallow entry, BrowserRoot renders that stored tree and its useLayoutEffect calls rememberHistoryStateSnapshot() without durable, so this line immediately demotes the entry into the bounded cache. After navigating Forward, invalidating caches (for example with router.refresh()), and going Back again, the snapshot is gone and the router requests the potentially nonexistent shallow pathname. A shallow entry must retain its durable status when the ordinary render-snapshot effect observes the same history index.

Useful? React with 👍 / 👎.


this.#snapshots.delete(options.historyIndex);
this.#snapshots.set(options.historyIndex, {
bfcacheVersion: options.bfcacheVersion,
Expand All @@ -75,13 +96,22 @@ export class HistoryStateSnapshotCache<TState> {
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 };
Expand Down Expand Up @@ -148,9 +178,14 @@ export class RestorableClientStateController<TState> {
this.#invalidateBfcacheIds();
}

rememberHistoryStateSnapshot(options: { historyIndex: number | null; state: TState }): void {
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,
});
Expand Down Expand Up @@ -246,22 +281,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 {
Expand Down
42 changes: 30 additions & 12 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3025,12 +3025,21 @@ 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,
hasAppNavigationRuntime()
? createExternalHistoryStatePreservingMetadata(
data,
window.history.state,
new URL(url ?? window.location.href, window.location.href).href,
)
: 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
Expand All @@ -3045,12 +3054,21 @@ 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,
hasAppNavigationRuntime()
? createExternalHistoryStatePreservingMetadata(
data,
window.history.state,
new URL(url ?? window.location.href, window.location.href).href,
)
: data,
unused,
url,
);
}
if (state.suppressUrlNotifyCount === 0) {
resetStaleLinkStatus();
commitClientNavigationState();
Expand Down
Loading