Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
27 changes: 15 additions & 12 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,13 @@ import {
} from "./app-browser-state.js";
import { AppBrowserHistoryController } from "./app-browser-history-controller.js";
import {
clearVisitedResponseCache,
createVisitedResponseCacheEntry,
deleteVisitedResponseCacheEntry,
findVisitedResponseCacheEntry,
isVisitedResponseCacheEntryCompatibleForNavigation,
isVisitedResponseCacheEntryFresh,
visitedResponseCache,
type VisitedResponseCacheEntry,
} from "./app-visited-response-cache.js";
import {
Expand Down Expand Up @@ -318,7 +323,6 @@ function isRouterStatePromise(
}

let latestClientParams: Record<string, string | string[]> = {};
const visitedResponseCache = new Map<string, VisitedResponseCacheEntry>();
let clientNavigationCacheGeneration = 0;
// Sticky bit: stays true once BrowserRoot has committed at least once. Used by
// the HMR handler to distinguish "still hydrating" (wait) from "was up, then
Expand Down Expand Up @@ -407,10 +411,6 @@ function stageClientParams(params: Record<string, string | string[]>): void {
replaceClientParamsWithoutNotify(params);
}

function clearVisitedResponseCache(): void {
visitedResponseCache.clear();
}

function clearPrefetchState(): void {
invalidatePrefetchCache();
optimisticRouteTemplates.clear();
Expand Down Expand Up @@ -679,8 +679,8 @@ function readVisitedResponseCacheCandidate(
navigationKind: NavigationKind,
): VisitedResponseCacheCandidate {
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const cached = visitedResponseCache.get(cacheKey);
if (!cached) {
const match = findVisitedResponseCacheEntry(visitedResponseCache, rscUrl, interceptionContext);
if (!match) {
return {
cacheKey,
entry: null,
Expand All @@ -692,15 +692,18 @@ function readVisitedResponseCacheCandidate(
}

return {
cacheKey,
entry: cached,
cacheKey: match.cacheKey,
entry: match.entry,
facts: {
candidate: "present",
fresh: isVisitedResponseCacheEntryFresh(cached, {
fresh: isVisitedResponseCacheEntryFresh(match.entry, {
navigationKind,
now: Date.now(),
}),
mountedSlotsMatch: cached.mountedSlotsHeader === mountedSlotsHeader,
mountedSlotsMatch: isVisitedResponseCacheEntryCompatibleForNavigation(
match.entry,
mountedSlotsHeader,
),
navigationKind,
},
};
Expand Down Expand Up @@ -731,7 +734,7 @@ function applyVisitedResponseCacheCandidateDecision(
}

function deleteVisitedResponse(rscUrl: string, interceptionContext: string | null): void {
visitedResponseCache.delete(AppElementsWire.encodeCacheKey(rscUrl, interceptionContext));
deleteVisitedResponseCacheEntry(visitedResponseCache, rscUrl, interceptionContext);
}

function storeVisitedResponseSnapshot(
Expand Down
17 changes: 17 additions & 0 deletions packages/vinext/src/server/app-elements-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ type AppElementsWireCodec = {
readonly unmatchedSlotValue: typeof APP_UNMATCHED_SLOT_WIRE_VALUE;
createMetadataEntries(input: AppElementsWireMetadataInput): AppElementsWireMetadataEntries;
decode(elements: AppWireElements): AppElements;
decodeCacheKey(input: string): {
interceptionContext: string | null;
rscUrl: string;
} | null;
encodeCacheKey(rscUrl: string, interceptionContext: string | null): string;
encodeLayoutId(treePath: string): string;
encodeOutgoingPayload(input: {
Expand Down Expand Up @@ -330,6 +334,18 @@ function createAppPayloadCacheKey(rscUrl: string, interceptionContext: string |
return appendInterceptionContext(rscUrl, interceptionContext);
}

function parseAppPayloadCacheKey(input: string): {
interceptionContext: string | null;
rscUrl: string;
} | null {
const parsed = parsePathWithInterception(input);
if (parsed === null) return null;
return {
interceptionContext: parsed.interceptionContext,
rscUrl: parsed.path,
};
}

function parsePathWithInterception(input: string): {
interceptionContext: string | null;
path: string;
Expand Down Expand Up @@ -884,6 +900,7 @@ export const AppElementsWire: AppElementsWireCodec = {
unmatchedSlotValue: APP_UNMATCHED_SLOT_WIRE_VALUE,
createMetadataEntries: createAppElementsWireMetadataEntries,
decode: normalizeAppElements,
decodeCacheKey: parseAppPayloadCacheKey,
encodeCacheKey: createAppPayloadCacheKey,
encodeLayoutId: createAppPayloadLayoutId,
encodeOutgoingPayload: buildOutgoingAppPayload,
Expand Down
106 changes: 103 additions & 3 deletions packages/vinext/src/server/app-visited-response-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { resolveCachedRscResponseExpiresAt, type CachedRscResponse } from "vinext/shims/navigation";
import type { AppElements } from "./app-elements.js";
import {
PREFETCH_CACHE_TTL,
resolveCachedRscResponseExpiresAt,
type CachedRscResponse,
} from "vinext/shims/navigation";
import { AppElementsWire, type AppElements } from "./app-elements.js";
import { stripRscCacheBustingSearchParam } from "./app-rsc-cache-busting.js";

type VisitedResponseCacheNavigationKind = "navigate" | "refresh" | "traverse";
type VisitedResponseCacheNavigationKind = "navigate" | "prefetch" | "refresh" | "traverse";

export type VisitedResponseCacheEntry = {
createdAt: number;
Expand All @@ -14,6 +19,7 @@ export type VisitedResponseCacheEntry = {

export const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000;
export const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000;
export const visitedResponseCache = new Map<string, VisitedResponseCacheEntry>();

export function createVisitedResponseCacheEntry(options: {
elements?: AppElements;
Expand Down Expand Up @@ -52,5 +58,99 @@ export function isVisitedResponseCacheEntryFresh(
return options.now - entry.createdAt < MAX_TRAVERSAL_CACHE_TTL;
}

if (options.navigationKind === "prefetch") {
return options.now <= entry.createdAt + PREFETCH_CACHE_TTL;
}

return entry.expiresAt > options.now;
}

export function clearVisitedResponseCache(): void {
visitedResponseCache.clear();
}

function normalizeVisitedResponseCacheLookupUrl(rscUrl: string): string | null {
try {
const url = new URL(rscUrl, "http://vinext.local");
stripRscCacheBustingSearchParam(url);
return `${url.pathname}${url.search}`;
} catch {
return null;
}
}

export function findVisitedResponseCacheEntry(
cache: Map<string, VisitedResponseCacheEntry>,
rscUrl: string,
interceptionContext: string | null,
): { cacheKey: string; entry: VisitedResponseCacheEntry } | null {
const exactCacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const exactEntry = cache.get(exactCacheKey);
if (exactEntry) {
return { cacheKey: exactCacheKey, entry: exactEntry };
}

const normalizedTarget = normalizeVisitedResponseCacheLookupUrl(rscUrl);
if (normalizedTarget === null) return null;

for (const [cacheKey, entry] of cache) {
const source = AppElementsWire.decodeCacheKey(cacheKey);
if (source === null) continue;
if (source.interceptionContext !== interceptionContext) continue;
if (normalizeVisitedResponseCacheLookupUrl(source.rscUrl) !== normalizedTarget) continue;
return { cacheKey, entry };
}

return null;
}

export function deleteVisitedResponseCacheEntry(
cache: Map<string, VisitedResponseCacheEntry>,
rscUrl: string,
interceptionContext: string | null,
): boolean {
const match = findVisitedResponseCacheEntry(cache, rscUrl, interceptionContext);
if (!match) return false;
return cache.delete(match.cacheKey);
}

export function isVisitedResponseCacheEntryCompatibleForNavigation(
entry: VisitedResponseCacheEntry,
mountedSlotsHeader: string | null,
): boolean {
return entry.mountedSlotsHeader === mountedSlotsHeader;
}

export function isVisitedResponseCacheEntryCompatibleForPrefetch(
entry: VisitedResponseCacheEntry,
mountedSlotsHeader: string | null,
): boolean {
return entry.elements !== undefined || entry.mountedSlotsHeader === mountedSlotsHeader;
}

export function claimVisitedResponseCacheEntryForPrefetch(
rscUrl: string,
interceptionContext: string | null,
mountedSlotsHeader: string | null,
): boolean {
const match = findVisitedResponseCacheEntry(visitedResponseCache, rscUrl, interceptionContext);
if (!match) return false;

if (
!isVisitedResponseCacheEntryFresh(match.entry, {
navigationKind: "prefetch",
now: Date.now(),
})
) {
visitedResponseCache.delete(match.cacheKey);
return false;
}

if (!isVisitedResponseCacheEntryCompatibleForPrefetch(match.entry, mountedSlotsHeader)) {
return false;
}

visitedResponseCache.delete(match.cacheKey);
visitedResponseCache.set(match.cacheKey, match.entry);
return true;
}
45 changes: 31 additions & 14 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,14 +371,21 @@ export function resolveAutoAppRoutePrefetch(href: string): {
return resolveMatchedAutoAppRoutePrefetch(match.route);
}

function resolveFullAppRoutePrefetch(): {
function resolveFullAppRoutePrefetch(href: string): {
cacheForNavigation: true;
prefetchShellFirst: boolean;
shouldPrefetch: true;
} {
const routes = typeof window === "undefined" ? undefined : window.__VINEXT_LINK_PREFETCH_ROUTES__;
const routeHref = routes === undefined ? null : toSameOriginRouteHref(href);
const match =
routes === undefined || routeHref === null
? null
: matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);

return {
cacheForNavigation: true,
prefetchShellFirst: true,
prefetchShellFirst: match?.route.canPrefetchLoadingShell === true,
shouldPrefetch: true,
};
}
Expand All @@ -395,8 +402,9 @@ function resolveFullAppRoutePrefetch(): {
* For Pages Router: warms the page chunk, prefetches data only for SSG pages,
* and falls back to a document prefetch hint when no page loader matches.
*
* Uses `requestIdleCallback` (or `setTimeout` fallback) to avoid blocking
* the main thread during initial page load.
* App Router and high-priority prefetches start immediately. Low-priority
* Pages Router fallback prefetches use `requestIdleCallback` (or `setTimeout`
* fallback) to avoid blocking the main thread during initial page load.
*/
function prefetchUrl(
href: string,
Expand Down Expand Up @@ -436,14 +444,7 @@ function prefetchUrl(
return;
}

const schedule =
priority === "high"
? (fn: () => void) => {
fn();
}
: (window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100)));

schedule(() => {
const runPrefetch = () => {
void (async () => {
if (hasAppNavigationRuntime()) {
if (isBotUserAgent(window.navigator?.userAgent ?? "")) return;
Expand All @@ -455,13 +456,15 @@ function prefetchUrl(
{ APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL },
headersModule,
{ resolveHybridClientRouteOwner },
{ claimVisitedResponseCacheEntryForPrefetch },
] = await Promise.all([
import("./navigation.js"),
import("../server/app-elements.js"),
import("../server/app-rsc-cache-busting.js"),
import("../server/app-rsc-render-mode.js"),
import("../server/headers.js"),
import("./internal/hybrid-client-route-owner.js"),
import("../server/app-visited-response-cache.js"),
]);
const {
getPrefetchInterceptionContext,
Expand Down Expand Up @@ -490,7 +493,7 @@ function prefetchUrl(
? resolveAutoAppRoutePrefetch(prefetchHref)
: mode === "full-after-shell"
? { cacheForNavigation: true, prefetchShellFirst: true, shouldPrefetch: true }
: resolveFullAppRoutePrefetch();
: resolveFullAppRoutePrefetch(prefetchHref);
if (!autoPrefetch.shouldPrefetch) return;

const interceptionContext = getPrefetchInterceptionContext(fullHref);
Expand Down Expand Up @@ -533,6 +536,12 @@ function prefetchUrl(
) {
return;
}
if (
autoPrefetch.cacheForNavigation &&
claimVisitedResponseCacheEntryForPrefetch(rscUrl, interceptionContext, mountedSlotsHeader)
) {
return;
}
prefetched.add(cacheKey);
// Next's `prefetchInlining` Segment Cache path reserves the final
// payload while a route-tree request is still pending. Vinext keeps a
Expand Down Expand Up @@ -650,7 +659,15 @@ function prefetchUrl(
})().catch((error) => {
console.error("[vinext] RSC prefetch setup error:", error);
});
});
};

if (priority === "high" || hasAppNavigationRuntime()) {
runPrefetch();
return;
}

const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100));
schedule(runPrefetch);
}

async function promotePrefetchEntriesForNavigation(href: string): Promise<void> {
Expand Down
Loading
Loading