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
49 changes: 37 additions & 12 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
GtechGovind marked this conversation as resolved.
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: () => {
Expand Down Expand Up @@ -2447,19 +2468,7 @@ function bootstrapHydration(
});

window.addEventListener("popstate", (event) => {
// The browser has already applied the history entry by the time popstate
// fires. App Router state does not include hashes, so matching the
// committed pathname/search proves this traversal does not need a new RSC
// payload. This covers both /page#target -> /page and /page -> /page#target.
// Notify the transition start so observers still see the URL change, then
// restore scroll directly and skip the RSC dispatch.
const href = window.location.href;
if (isSameAppRoutePopstateTarget(href)) {
notifyAppRouterTransitionStart(href, "traverse");
historyController.commitTraversalIndexFromHistoryState(event.state);
restorePopstateScrollPosition(event.state);
return;
}
const snapshotNavigationId = browserNavigationController.beginNavigation();
if (
restoreHistoryStateSnapshot(event.state, snapshotNavigationId, () => {
Expand All @@ -2482,6 +2491,22 @@ function bootstrapHydration(
browserNavigationController.finalizeNavigation(snapshotNavigationId, null);
return;
}

// The browser has already applied the history entry by the time popstate
// fires. App Router state does not include hashes, so matching the
// committed pathname/search means this traversal does not need a new RSC
// payload once any entry-specific snapshot has had the first opportunity
// to restore its saved tree. This covers both /page#target -> /page and
// /page -> /page#target. Notify the transition start so observers still see
// the URL change, then restore scroll directly and skip the RSC dispatch.
if (isSameAppRoutePopstateTarget(href)) {
notifyAppRouterTransitionStart(href, "traverse");
historyController.commitTraversalIndexFromHistoryState(event.state);
commitClientNavigationState();
restorePopstateScrollPosition(event.state);
browserNavigationController.finalizeNavigation(snapshotNavigationId, null);
return;
}
browserNavigationController.finalizeNavigation(snapshotNavigationId, null);
handlePopstate(event);
});
Expand Down
50 changes: 46 additions & 4 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 @@ -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;
};

Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -100,8 +109,8 @@ export class AppBrowserHistoryController {
readonly #restorableClientState: RestorableClientStateController<AppRouterState>;
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
Expand Down Expand Up @@ -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();
Expand All @@ -225,6 +261,7 @@ export class AppBrowserHistoryController {
this.#replaceHistoryState(nextHistoryState, href);
} else {
this.#pushHistoryState(nextHistoryState, href);
this.#restorableClientState.pruneHistoryStateSnapshotsAfter(previousHistoryIndex);
}
this.commitHistoryTraversalIndex(navigationHistoryIndex);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -288,6 +327,9 @@ export class AppBrowserHistoryController {
this.commitHistoryTraversalIndex(options.targetHistoryIndex);
}
}
if (navigationHistoryIndex !== null) {
this.#restorableClientState.supersedeDurableHistoryStateSnapshot(navigationHistoryIndex);
}
}

syncCurrentHistoryStatePreviousNextUrl(
Expand Down
119 changes: 106 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,54 @@ export class HistoryStateSnapshotCache<TState> {
this.#snapshots.clear();
}

remember(options: { bfcacheVersion: number; historyIndex: number | null; state: TState }): void {
pruneAfter(historyIndex: number | null): void {
if (historyIndex === null) {
// An indexed snapshot belongs to the app-owned branch created after the
// document's metadata-less entries. Pushing from one of those older
// entries discards that whole branch, so none of its snapshots remain
// reachable even though there is no numeric cutoff to compare against.
this.#snapshots.clear();
this.#durableSnapshots.clear();
return;
}
for (const snapshotIndex of this.#snapshots.keys()) {
if (snapshotIndex > historyIndex) this.#snapshots.delete(snapshotIndex);
}
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);
Comment thread
GtechGovind marked this conversation as resolved.
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);
Comment thread
GtechGovind marked this conversation as resolved.

this.#snapshots.delete(options.historyIndex);
this.#snapshots.set(options.historyIndex, {
bfcacheVersion: options.bfcacheVersion,
Expand All @@ -75,13 +126,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 +208,22 @@ export class RestorableClientStateController<TState> {
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,
});
Expand Down Expand Up @@ -246,22 +319,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
Loading