Skip to content
1 change: 1 addition & 0 deletions packages/vinext/src/client/navigation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type NavigationRuntimeNavigate = (
traversalIntent?: NavigationRuntimeTraversalIntent,
scrollIntent?: AppRouterScrollIntent | null,
visibleCommitMode?: NavigationRuntimeVisibleCommitMode,
bypassNavigationCache?: boolean,
) => Promise<void>;

export type NavigationRuntimeFunctions = {
Expand Down
50 changes: 41 additions & 9 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
createCachedRscResponseSnapshot,
createClientNavigationRenderSnapshot,
deletePrefetchResponseSnapshot,
disableNavigationResponsePrefetchCacheReuse,
DYNAMIC_NAVIGATION_CACHE_TTL,
PREFETCH_CACHE_TTL,
getClientNavigationRenderContext,
Expand Down Expand Up @@ -128,8 +129,11 @@ import {
import { AppBrowserHistoryController } from "./app-browser-history-controller.js";
import {
createVisitedResponseCacheEntry,
deleteAllVisitedResponseCacheEntries,
deleteInvalidatedHistoryRestoreEntries,
deleteVisitedResponseCacheEntry,
findVisitedResponseCacheEntry,
hasNavigationResponseHistoryLifetime,
isVisitedResponseCacheEntryFresh,
type VisitedResponseCacheEntry,
} from "./app-visited-response-cache.js";
Expand Down Expand Up @@ -390,6 +394,15 @@ function restoreHistoryStateSnapshot(
});
if (!restored) return false;

// History entries restore their own visible tree, but a later Link click is
// a new navigation. Demote unbounded response snapshots published by the
// route we just left while retaining explicit prefetches and responses whose
// segment-cache lifetime licenses reuse. Advance the generation first so an
// async publication already waiting on a response body cannot repopulate a
// departed unbounded response after the caches are pruned.
clientNavigationCacheGeneration += 1;
deleteInvalidatedHistoryRestoreEntries(visitedResponseCache);
disableNavigationResponsePrefetchCacheReuse();
commitClientNavigationState(navId, { releaseSnapshot: false });
return true;
}
Expand Down Expand Up @@ -769,9 +782,14 @@ function storeVisitedResponseSnapshot(
elements?: AppElements,
seedPrefetchCache: boolean = true,
prefetchSnapshot: CachedRscResponse = snapshot,
reuseAfterHistoryRestore: boolean = false,
): () => void {
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
visitedResponseCache.delete(cacheKey);
// Router-state fingerprints intentionally give requests for the same visible
// route distinct cache-busting URLs. A newly committed response supersedes
// every older fingerprint variant; leaving one behind lets normalized lookup
// replay stale page output after a later navigation.
deleteAllVisitedResponseCacheEntries(visitedResponseCache, rscUrl, interceptionContext);
evictVisitedResponseCacheIfNeeded();
const now = Date.now();
const entry = createVisitedResponseCacheEntry({
Expand All @@ -781,6 +799,7 @@ function storeVisitedResponseSnapshot(
mountedSlotsHeader: requestMountedSlotsHeader,
params,
response: snapshot,
reuseAfterHistoryRestore,
});
visitedResponseCache.set(cacheKey, entry);
if (seedPrefetchCache) {
Expand All @@ -790,6 +809,7 @@ function storeVisitedResponseSnapshot(
interceptionContext,
requestMountedSlotsHeader,
prefetchFallbackTtlMs,
reuseAfterHistoryRestore,
);
}
return () => {
Expand All @@ -802,10 +822,9 @@ function storeVisitedResponseSnapshot(
};
}

// Build the absolute current-document href the early-intent planner compares
// against the navigation target. The committed snapshot carries a base-stripped
// pathname plus parsed search params; the planner re-strips the base (a no-op on
// an already-stripped path) so both sides reduce to the same canonical form.
// Build the absolute app-relative href the early-intent planner compares
// against the browser-space navigation target. The committed snapshot already
// has basePath stripped; its explicit URL-space tag prevents a second strip.
function clientNavigationSnapshotHref(snapshot: ClientNavigationRenderSnapshot): string {
return `${window.location.origin}${createSnapshotPathAndSearch(snapshot)}`;
}
Expand Down Expand Up @@ -1570,6 +1589,9 @@ function bootstrapHydration(
mountedSlotsHeader,
elements,
false,
snapshot,
initialRscBootstrap?.initialCacheKind === "static" ||
metadata.interceptionContext !== null,
);
});
})
Expand Down Expand Up @@ -1655,6 +1677,7 @@ function bootstrapHydration(
traversalIntent?: HistoryTraversalIntent,
scrollIntent?: AppRouterScrollIntent | null,
visibleCommitMode: NavigationRuntimeVisibleCommitMode = "transition",
initialBypassNavigationCache?: boolean,
): Promise<void> {
abortSupersededNavigation();
const navigationAbortController = new AbortController();
Expand Down Expand Up @@ -1759,10 +1782,16 @@ function bootstrapHydration(
// already short-circuited before reaching this loop, so for a "navigate"
// here the decision is always a flight navigation and only its
// cache-bypass bit is consumed.
const usesInitialNavigationCachePolicy =
navigationKind === "navigate" &&
currentHref === href &&
redirectCount === redirectDepth &&
initialBypassNavigationCache !== undefined;
const earlyIntentDecision =
navigationKind === "navigate"
navigationKind === "navigate" && !usesInitialNavigationCachePolicy
? navigationPlanner.classifyEarlyNavigationIntent({
basePath: __basePath,
currentUrlSpace: "appRelativeSnapshot",
currentHref: clientNavigationSnapshotHref(
navigationInitiationState.navigationSnapshot,
),
Expand All @@ -1773,9 +1802,10 @@ function bootstrapHydration(
targetHref: url.href,
})
: null;
const shouldBypassNavigationCache =
earlyIntentDecision?.kind === "flightNavigation" &&
earlyIntentDecision.bypassNavigationCache;
const shouldBypassNavigationCache = usesInitialNavigationCachePolicy
? initialBypassNavigationCache
: earlyIntentDecision?.kind === "flightNavigation" &&
earlyIntentDecision.bypassNavigationCache;
// The client reuse manifest is excluded from VINEXT_RSC_VARY_HEADER, so
// it never affects the cache-busting URL. Defer producing it until the
// visited-response cache miss is confirmed below — its producer iterates
Expand Down Expand Up @@ -2331,6 +2361,7 @@ function bootstrapHydration(
undefined,
true,
prefetchSnapshot,
true,
);
} else {
const state = committedState;
Expand All @@ -2355,6 +2386,7 @@ function bootstrapHydration(
committedElements,
true,
prefetchSnapshot,
interceptionContext !== null || hasNavigationResponseHistoryLifetime(snapshot),
);
}
} catch {
Expand Down
43 changes: 42 additions & 1 deletion packages/vinext/src/server/app-browser-visible-commit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation";
import type { RouteManifest } from "../routing/app-route-graph.js";
import { mergeElements } from "vinext/shims/slot";
import { resolveAppPageRouteStateKey } from "./app-page-segment-state.js";
import {
AppElementsWire,
normalizeAppElementsSlotBindings,
type AppElements,
type AppElementsSlotBinding,
Expand Down Expand Up @@ -72,6 +74,21 @@ type ClassifiedPendingNavigationCommit = {
trace: NavigationTrace;
};

function hasSameServerTemplateSeedIdentity(
id: string,
currentSnapshot: ClientNavigationRenderSnapshot,
nextSnapshot: ClientNavigationRenderSnapshot,
): boolean {
const parsed = AppElementsWire.parseElementKey(id);
if (parsed?.kind !== "template") return false;

const templateSegments = parsed.treePath.split("/").filter(Boolean);
return (
resolveAppPageRouteStateKey(templateSegments, currentSnapshot.params) ===
resolveAppPageRouteStateKey(templateSegments, nextSnapshot.params)
);
}

export function applyApprovedVisibleCommit(
state: AppRouterState,
commit: ApprovedVisibleCommit,
Expand Down Expand Up @@ -206,10 +223,34 @@ function reduceApprovedVisibleCommitState(
action.operation.lane === "hmr"
? hmrUniquePreserveElementIds
: bfcacheCompatiblePreserveElementIds;
// Next stores a server template's rendered seed on the persistent layout
// router. The template's child-segment state key may change to remount
// client state without replacing that seed. Compare only params bound by
// the template's own route prefix; refresh/HMR or a changed owner param
// still installs fresh server output.
const preserveTemplateIds =
action.reuseCurrentBfcacheIds &&
(action.operation.lane === "navigation" ||
action.operation.lane === "traverse" ||
action.operation.lane === "server-action")
? Object.keys(state.elements).filter(
(id) =>
Object.hasOwn(action.elements, id) &&
hasSameServerTemplateSeedIdentity(
id,
state.navigationSnapshot,
action.navigationSnapshot,
),
)
: [];
const mergedPreserveElementIds =
preserveTemplateIds.length === 0
? preserveElementIds
: [...new Set([...preserveElementIds, ...preserveTemplateIds])];
const mergedElements = mergeElements(state.elements, action.elements, {
clearAbsentSlots: action.type === "traverse" || !action.reuseCurrentBfcacheIds,
preserveAbsentSlots: action.reuseCurrentBfcacheIds && commit.decision.preserveAbsentSlots,
preserveElementIds,
preserveElementIds: mergedPreserveElementIds,
preservePreviousSlotIds,
});
return commitVisibleRouterState(
Expand Down
32 changes: 32 additions & 0 deletions packages/vinext/src/server/app-visited-response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,28 @@ export type VisitedResponseCacheEntry = {
mountedSlotsHeader: string | null;
params: Record<string, string | string[]>;
response: CachedRscResponse;
reuseAfterHistoryRestore: boolean;
};

export const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000;
export const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000;

export function hasNavigationResponseHistoryLifetime(snapshot: CachedRscResponse): boolean {
const dynamicStaleTime =
snapshot.completedDynamicStaleTimeSeconds ?? snapshot.dynamicStaleTimeSeconds;
return dynamicStaleTime !== undefined
? dynamicStaleTime > 0
: snapshot.serverStaleTime !== undefined;
}

export function createVisitedResponseCacheEntry(options: {
elements?: AppElements;
fallbackTtlMs?: number;
now: number;
mountedSlotsHeader?: string | null;
params: Record<string, string | string[]>;
response: CachedRscResponse;
reuseAfterHistoryRestore?: boolean;
}): VisitedResponseCacheEntry {
return {
createdAt: options.now,
Expand All @@ -35,6 +45,7 @@ export function createVisitedResponseCacheEntry(options: {
mountedSlotsHeader: options.mountedSlotsHeader ?? null,
params: options.params,
response: options.response,
reuseAfterHistoryRestore: options.reuseAfterHistoryRestore === true,
};
}

Expand Down Expand Up @@ -113,3 +124,24 @@ export function deleteVisitedResponseCacheEntry(
if (!match) return false;
return cache.delete(match.cacheKey);
}

export function deleteAllVisitedResponseCacheEntries(
cache: Map<string, VisitedResponseCacheEntry>,
rscUrl: string,
interceptionContext: string | null,
): number {
let deleted = 0;
while (deleteVisitedResponseCacheEntry(cache, rscUrl, interceptionContext)) {
deleted++;
}
return deleted;
}

export function deleteInvalidatedHistoryRestoreEntries(
cache: Map<string, VisitedResponseCacheEntry>,
): void {
for (const [cacheKey, entry] of cache) {
if (entry.reuseAfterHistoryRestore) continue;
cache.delete(cacheKey);
}
}
55 changes: 42 additions & 13 deletions packages/vinext/src/server/navigation-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,23 +228,28 @@ export type RscFetchResultDecision =
// planner inputs (route manifest, mounted slots) join later slices once prefetch
// reuse and the remaining hard-navigation causes route through this surface.
export type EarlyNavigationIntentFacts = {
// App basePath, stripped from both pathnames before comparison.
// App basePath. Navigation targets are browser-space URLs and are stripped
// once before comparison.
basePath: string;
// The current visible document URL (window.location.href at navigation start),
// absolute so it can anchor relative target resolution.
// Whether currentHref is the visible browser URL or a committed App Router
// snapshot URL. Snapshot pathnames are already app-relative and must never
// have basePath stripped again: an app route whose first segment equals the
// configured basePath would otherwise lose a real route segment.
currentUrlSpace: "appRelativeSnapshot" | "browser";
// The current URL, absolute so it can anchor relative target resolution.
currentHref: string;
// push/replace history intent carried through to the scroll executor.
mode: "push" | "replace";
// Whether the navigation requested scroll (Link/router scroll option).
scroll: boolean;
// The navigation target, absolute or relative to currentHref.
// The browser-space navigation target, absolute or relative to currentHref.
targetHref: string;
};

export type EarlyNavigationIntentDecision =
| {
kind: "sameDocumentScroll";
// Always non-empty: same-document scroll is only chosen for a hash target.
// Empty when removing the current hash; otherwise the new hash target.
hash: string;
mode: "push" | "replace";
scroll: boolean;
Expand Down Expand Up @@ -604,6 +609,14 @@ function createEarlyNavigationIntentTrace(
return createNavigationTrace(reasonCode, { targetHref: facts.targetHref });
}

function appRelativeNavigationPathname(
pathname: string,
basePath: string,
urlSpace: "appRelativeSnapshot" | "browser",
): string {
return urlSpace === "browser" ? stripBasePath(pathname, basePath) : pathname;
}

function classifyEarlyNavigationIntent(
facts: EarlyNavigationIntentFacts,
): EarlyNavigationIntentDecision {
Expand Down Expand Up @@ -631,19 +644,35 @@ function classifyEarlyNavigationIntent(
// here, so it cannot assume the caller already filtered same-origin: gate both
// same-document outcomes on origin so a different host falls through to an
// ordinary flight.
const samePathname =
current.origin === next.origin &&
stripBasePath(current.pathname, facts.basePath) ===
stripBasePath(next.pathname, facts.basePath);
const currentAppPathname = appRelativeNavigationPathname(
current.pathname,
facts.basePath,
facts.currentUrlSpace,
);
const targetAppPathname = appRelativeNavigationPathname(next.pathname, facts.basePath, "browser");
const samePathname = current.origin === next.origin && currentAppPathname === targetAppPathname;
// Compare serialised search params rather than raw search strings, matching the
// previous same-page-search predicate, so encoding differences that parse to
// the same query (e.g. "%20" vs "+") are not read as a search change. App
// Router snapshots reach currentHref through createSnapshotPathAndSearch();
// reparsing and serialising that canonical query is idempotent and preserves
// key order. We intentionally do not sort, since query order can be observable.
// Router snapshots retain the raw search spelling separately from parsed
// params. We intentionally do not sort, since query order can be observable.
const sameSearch = current.searchParams.toString() === next.searchParams.toString();

if (samePathname && sameSearch && next.hash !== "") {
// A Link to the exact current URL still invalidates the page segment in
// Next.js, including when both URLs contain the same non-empty hash. Keep raw
// search/hash equality here: equivalent query encodings are the same page but
// not the exact same URL spelling.
if (samePathname && current.search === next.search && current.hash === next.hash) {
return {
bypassNavigationCache: true,
kind: "flightNavigation",
trace: createEarlyNavigationIntentTrace(NavigationTraceReasonCodes.samePageRefresh, facts),
};
}

// Any hash change is same-document, including removing the current hash. An
// unchanged hash reached exact identity above and refreshes the page segment.
if (samePathname && sameSearch && current.hash !== next.hash) {
return {
hash: next.hash,
kind: "sameDocumentScroll",
Expand Down
2 changes: 2 additions & 0 deletions packages/vinext/src/server/navigation-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const NavigationTraceReasonCodes = {
rscCompatibilityMismatch: "NC_RSC_COMPAT_MISMATCH",
rscNavigationError: "NC_RSC_NAV_ERROR",
sameDocumentScroll: "NC_SAME_DOC_SCROLL",
samePageRefresh: "NC_SAME_PAGE_REFRESH",
samePageSearch: "NC_SAME_PAGE_SEARCH",
serverActionRedirectCompatibilityMismatch: "NC_SA_REDIRECT_COMPAT",
serverActionRscCompatibilityMismatch: "NC_SA_RSC_COMPAT",
Expand Down Expand Up @@ -62,6 +63,7 @@ export const NavigationTraceReasonCodes = {
rscCompatibilityMismatch: "NC_RSC_COMPAT_MISMATCH";
rscNavigationError: "NC_RSC_NAV_ERROR";
sameDocumentScroll: "NC_SAME_DOC_SCROLL";
samePageRefresh: "NC_SAME_PAGE_REFRESH";
samePageSearch: "NC_SAME_PAGE_SEARCH";
serverActionRedirectCompatibilityMismatch: "NC_SA_REDIRECT_COMPAT";
serverActionRscCompatibilityMismatch: "NC_SA_RSC_COMPAT";
Expand Down
Loading
Loading