diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index db187927f8..d18dbfad32 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -145,6 +145,178 @@ export function hasNamedExport(code: string, name: string): boolean { return hasNamedExportInProgram(program, name); } +/** + * Returns whether a named export is statically known to use an object property + * with the requested string value. Local aliases are followed, while external + * re-exports remain unknown because evaluating user modules during route scans + * would run application code. + */ +export function hasNamedExportObjectStringProperty( + code: string, + name: string, + property: string, + expectedValue: string, +): boolean { + const program = parseRouteModule(code); + if (!program) return false; + + const localName = findExportedLocalNameInProgram(program, name); + if (localName === null) return false; + const initializer = + findExportedConstInitializerInProgram(program, name) ?? + findLocalConstInitializerInProgram(program, localName); + if (initializer === null) return false; + + const expression = resolveLocalConstExpression(program, initializer, new Set()); + if (expression.type !== "ObjectExpression") return false; + for (const candidate of expression.properties) { + if ( + candidate.type !== "Property" || + candidate.computed || + propertyKeyName(candidate.key) !== property + ) { + continue; + } + const value = resolveLocalConstExpression(program, candidate.value, new Set()); + return value.type === "Literal" && value.value === expectedValue; + } + return false; +} + +export type NamedExternalReexport = { + importedName: string; + source: string; +}; + +export type NamedExportObjectStringPropertyAnalysis = { + hasExport: boolean; + hasUseClientDirective: boolean; + hasStaticValue: boolean; + staticValue: unknown; + propertyValue: string | null; + reexport: NamedExternalReexport | null; +}; + +/** Analyze an object-valued named export with one AST parse. */ +export function analyzeNamedExportObjectStringProperty( + code: string, + name: string, + property: string, +): NamedExportObjectStringPropertyAnalysis { + const program = parseRouteModule(code); + if (!program) { + return { + hasExport: false, + hasUseClientDirective: false, + hasStaticValue: false, + staticValue: undefined, + propertyValue: null, + reexport: null, + }; + } + + const reexport = findNamedExternalReexportInProgram(program, name); + const localName = findExportedLocalNameInProgram(program, name); + let hasStaticValue = false; + let staticValue: unknown; + let propertyValue: string | null = null; + if (localName !== null) { + const initializer = + findExportedConstInitializerInProgram(program, name) ?? + findLocalConstInitializerInProgram(program, localName); + if (initializer !== null) { + const expression = resolveLocalConstExpression(program, initializer, new Set()); + const extractedValue = extractStaticJsonValue(expression, (candidate) => + resolveLocalConstExpression(program, candidate, new Set()), + ); + if (extractedValue !== UNSUPPORTED_STATIC_VALUE) { + hasStaticValue = true; + staticValue = extractedValue; + } + if (expression.type === "ObjectExpression") { + for (const candidate of expression.properties) { + if ( + candidate.type !== "Property" || + candidate.computed || + propertyKeyName(candidate.key) !== property + ) { + continue; + } + const value = resolveLocalConstExpression(program, candidate.value, new Set()); + if (value.type === "Literal" && typeof value.value === "string") { + propertyValue = value.value; + } + break; + } + } + } + } + + return { + hasExport: localName !== null || reexport !== null, + hasUseClientDirective: program.body.some( + (node) => node.type === "ExpressionStatement" && node.directive === "use client", + ), + hasStaticValue, + staticValue, + propertyValue, + reexport, + }; +} + +/** Returns the local module specifier that supplies a named re-export. */ +export function findNamedExternalReexport( + code: string, + name: string, +): NamedExternalReexport | null { + const program = parseRouteModule(code); + if (!program) return null; + + return findNamedExternalReexportInProgram(program, name); +} + +function findNamedExternalReexportInProgram( + program: Program, + name: string, +): NamedExternalReexport | null { + for (const node of program.body) { + if ( + node.type !== "ExportNamedDeclaration" || + node.exportKind === "type" || + node.source === null || + typeof node.source.value !== "string" + ) { + continue; + } + for (const specifier of node.specifiers) { + if (specifier.exportKind !== "type" && moduleExportNameValue(specifier.exported) === name) { + const importedName = moduleExportNameValue(specifier.local); + if (importedName !== null) { + return { importedName, source: node.source.value }; + } + } + } + } + + return null; +} + +function findExportedLocalNameInProgram(program: Program, name: string): string | null { + for (const node of program.body) { + if (node.type !== "ExportNamedDeclaration" || node.exportKind === "type") continue; + if (declarationHasBindingName(node.declaration, name)) return name; + + for (const specifier of node.specifiers) { + if (specifier.exportKind === "type") continue; + if (moduleExportNameValue(specifier.exported) === name) { + return moduleExportNameValue(specifier.local); + } + } + } + + return null; +} + /** Returns true when Next.js' analyzer recognizes the requested export name. */ export function hasExportedName(code: string, name: string): boolean { const program = parseRouteModule(code); @@ -191,6 +363,20 @@ function unwrapStaticExpression(expression: Expression): Expression { return current; } +function resolveLocalConstExpression( + program: Program, + expression: Expression, + visited: Set, +): Expression { + const unwrapped = unwrapStaticExpression(expression); + if (unwrapped.type !== "Identifier" || visited.has(unwrapped.name)) return unwrapped; + + const initializer = findLocalConstInitializerInProgram(program, unwrapped.name); + if (initializer === null) return unwrapped; + visited.add(unwrapped.name); + return resolveLocalConstExpression(program, initializer, visited); +} + function findExportedConstInitializer(code: string, name: string): Expression | null { const program = parseRouteModule(code); if (!program) return null; @@ -213,6 +399,17 @@ function findExportedConstInitializerInProgram(program: Program, name: string): return null; } +function findLocalConstInitializerInProgram(program: Program, name: string): Expression | null { + for (const node of program.body) { + if (node.type !== "VariableDeclaration" || node.kind !== "const") continue; + for (const declarator of node.declarations) { + if (bindingName(declarator.id) === name) return declarator.init; + } + } + + return null; +} + /** * Extracts the string value of `export const = "value"`. * Handles TypeScript annotations/assertions and no-substitution template literals. @@ -290,8 +487,12 @@ function propertyKeyName(key: PropertyKey): string | null { return null; } -function extractStaticJsonValue(expression: Expression): unknown { - const value = unwrapStaticExpression(expression); +function extractStaticJsonValue( + expression: Expression, + resolveExpression: (expression: Expression) => Expression = unwrapStaticExpression, + visiting: Set = new Set(), +): unknown { + const value = resolveExpression(expression); if (value.type === "Literal") { if ( @@ -309,28 +510,48 @@ function extractStaticJsonValue(expression: Expression): unknown { return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw ?? ""; } + if (value.type === "Identifier" && value.name === "undefined") { + return undefined; + } + if (value.type === "ArrayExpression") { + if (visiting.has(value)) return UNSUPPORTED_STATIC_VALUE; + visiting.add(value); const items: unknown[] = []; - for (const element of value.elements) { - if (!element || element.type === "SpreadElement") return UNSUPPORTED_STATIC_VALUE; - const item = extractStaticJsonValue(element); - if (item === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE; - items.push(item); + try { + for (const element of value.elements) { + if (!element) { + items.push(undefined); + continue; + } + if (element.type === "SpreadElement") return UNSUPPORTED_STATIC_VALUE; + const item = extractStaticJsonValue(element, resolveExpression, visiting); + if (item === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE; + items.push(item); + } + return items; + } finally { + visiting.delete(value); } - return items; } if (value.type === "ObjectExpression") { + if (visiting.has(value)) return UNSUPPORTED_STATIC_VALUE; + visiting.add(value); const object: Record = {}; - for (const property of value.properties) { - if (property.type !== "Property" || property.computed) return UNSUPPORTED_STATIC_VALUE; - const key = propertyKeyName(property.key); - if (!key) return UNSUPPORTED_STATIC_VALUE; - const propertyValue = extractStaticJsonValue(property.value); - if (propertyValue === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE; - object[key] = propertyValue; + try { + for (const property of value.properties) { + if (property.type !== "Property" || property.computed) return UNSUPPORTED_STATIC_VALUE; + const key = propertyKeyName(property.key); + if (!key) return UNSUPPORTED_STATIC_VALUE; + const propertyValue = extractStaticJsonValue(property.value, resolveExpression, visiting); + if (propertyValue === UNSUPPORTED_STATIC_VALUE) return UNSUPPORTED_STATIC_VALUE; + object[key] = propertyValue; + } + return object; + } finally { + visiting.delete(value); } - return object; } return UNSUPPORTED_STATIC_VALUE; diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 7580b06567..bdea745ead 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -35,6 +35,11 @@ type NavigationRuntimePrefetchRouterState = { routeId: string; }; +type NavigationRuntimeRetainedLayoutClaim = Readonly<{ + layoutId: string; + provider: unknown; +}>; + type NavigationRuntimeTraversalIntent = { direction: "back" | "forward" | "unknown"; historyState: unknown; @@ -75,7 +80,10 @@ export type NavigationRuntimeFunctions = { */ notifyLinkNavigationStart?: () => void; pingVisibleLinks?: () => void; - preparePrefetchResponse?: (response: Response) => Promise; + preparePrefetchResponse?: ( + response: Response, + retainedLayoutClaims?: readonly NavigationRuntimeRetainedLayoutClaim[], + ) => Promise; }; export type NavigationRuntimeBootstrap = { diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index 2b98b95338..92396bf7f6 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -11,6 +11,8 @@ import { isUnknownRecord } from "../utils/record.js"; export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; documentOnly?: boolean; + hasInstant?: boolean; + hasRuntimeInstant?: boolean; isDynamic: boolean; patternParts: string[]; requiresDynamicNavigationRequest?: boolean; diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index f027bd8d5d..a606bd7f04 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -24,11 +24,12 @@ export function generateBrowserEntry( beforeFiles: [], fallback: [], }, + cacheComponents = false, ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const reactInstanceBootstrapPath = resolveClientRuntimeModule("react-instance-bootstrap"); const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime"); - const prefetchRoutes = toLinkPrefetchRoutes(routes); + const prefetchRoutes = toLinkPrefetchRoutes(routes, cacheComponents); const clientRewrites = toClientRewrites(rewrites); return `import ${JSON.stringify(reactInstanceBootstrapPath)}; @@ -115,6 +116,8 @@ export function toLinkPrefetchRoute( ): VinextLinkPrefetchRoute { return { canPrefetchLoadingShell: hasLoadingBoundary(route, hasSiblingInterceptLoading), + ...(route.hasInstant ? { hasInstant: true } : {}), + ...(route.hasRuntimeInstant ? { hasRuntimeInstant: true } : {}), patternParts: [...route.patternParts], isDynamic: route.isDynamic, ...(requiresDynamicNavigationRequest(route) ? { requiresDynamicNavigationRequest: true } : {}), @@ -123,7 +126,24 @@ export function toLinkPrefetchRoute( } /** Project App routes together so sibling-intercept loading is applied to its target route. */ -export function toLinkPrefetchRoutes(routes: readonly AppRoute[]): VinextLinkPrefetchRoute[] { +export function toLinkPrefetchRoutes( + routes: readonly AppRoute[], + cacheComponents = false, +): VinextLinkPrefetchRoute[] { + const clientInstantRoute = routes.find((route) => route.hasInstantConfigInClientModule); + if (clientInstantRoute) { + throw new Error( + `Page "${clientInstantRoute.pattern}" cannot export "unstable_instant" from a Client Component module. To use this API, convert this module to a Server Component by removing the "use client" directive.`, + ); + } + if (!cacheComponents) { + const instantRoute = routes.find((route) => route.hasInstantConfig); + if (instantRoute) { + throw new Error( + `Page "${instantRoute.pattern}" cannot use \`export const unstable_instant = ...\` without enabling \`cacheComponents\`.`, + ); + } + } const siblingInterceptLoadingTargets: string[][] = []; for (const route of routes) { for (const intercept of route.siblingIntercepts) { diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index e5d633e8f5..f3df04fb33 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -750,6 +750,7 @@ export default createAppRscHandler({ draftModeSecret: __draftModeSecret, dispatchMatchedPage({ clientReuseManifest, + retainedPrefetchLayoutIds, cleanPathname, displayPathname, formState, @@ -820,6 +821,7 @@ export default createAppRscHandler({ isRscRequest, request, mountedSlotsHeader, + retainedPrefetchLayoutIds, renderMode, observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess === true, observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true, @@ -828,6 +830,7 @@ export default createAppRscHandler({ }, layoutParamAccess, displayPathname, scriptNonce); }, clientReuseManifest, + retainedPrefetchLayoutIds, cleanPathname, displayPathname, clearRequestContext() { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2ed778e8fc..03bc325159 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -64,6 +64,7 @@ import { type RouteClassificationManifest, } from "./build/route-classification-manifest.js"; import { + analyzeNamedExportObjectStringProperty, extractMiddlewareMatcherConfig, extractMiddlewareMatcherConfigValue, hasExportedName, @@ -1431,6 +1432,27 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Production builds leave it null and scan the configured public directory // once while generating the RSC entry. let devPublicFileRoutes: Set | null = null; + // Source modules that currently contribute `unstable_instant` metadata to + // this plugin instance's App Router graph. Kept per plugin so concurrent + // Vite servers cannot clear or overwrite each other's HMR dependencies. + let instantConfigDependencies = new Set(); + let instantRouteMetadataSignature: string | null = null; + const readInstantRouteMetadataSignature = ( + graph: Awaited>, + ): string => + JSON.stringify( + graph.routes.map((route) => [ + route.pattern, + route.hasInstant === true, + route.hasRuntimeInstant === true, + route.hasInstantConfig === true, + route.hasInstantConfigInClientModule === true, + ]), + ); + const captureInstantRouteMetadata = (graph: Awaited>): void => { + instantConfigDependencies = graph.instantConfigDependencies; + instantRouteMetadataSignature = readInstantRouteMetadataSignature(graph); + }; let publicDirConflictOptions: Parameters[0] | null = null; let rscCompatibilityId: string | undefined; let draftModeSecret = getPagesPreviewModeId(); @@ -1518,9 +1540,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // `Router.prefetch` can mark App Router targets on `Router.components` // with `{ __appRouter: true }`. See `pages-client-entry.ts` and issue // #1526 for the Next.js parity rationale. - const appPrefetchRoutes = hasAppDir - ? toLinkPrefetchRoutes(await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher)) - : []; + let appPrefetchRoutes: ReturnType = []; + if (hasAppDir) { + const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); + captureInstantRouteMetadata(graph); + appPrefetchRoutes = toLinkPrefetchRoutes(graph.routes, nextConfig.cacheComponents); + } return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { appPrefetchRoutes, instrumentationClientPath, @@ -3861,6 +3886,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); + captureInstantRouteMetadata(graph); // In a hybrid build, the App browser entry also exposes the Pages // route manifest so a user who lands on an App page can still // see Pages ownership from a `` click. @@ -3888,6 +3914,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { graph.routeManifest, pagesPrefetchRoutes, nextConfig.rewrites, + nextConfig.cacheComponents, ); } if (id === RESOLVED_APP_CAPABILITIES && hasAppDir) { @@ -4405,14 +4432,24 @@ export const loadServerActionClient = ${ } } - function invalidateHybridClientEntries() { - if (!hasAppDir || !hasPagesDir) return; + function invalidateAppBrowserEntry() { for (const env of Object.values(server.environments)) { - for (const id of [RESOLVED_CLIENT_ENTRY, RESOLVED_APP_BROWSER_ENTRY]) { - const mod = env.moduleGraph.getModuleById(id); - if (mod) env.moduleGraph.invalidateModule(mod); - } + const mod = env.moduleGraph.getModuleById(RESOLVED_APP_BROWSER_ENTRY); + if (mod) env.moduleGraph.invalidateModule(mod); + } + } + + function invalidatePagesClientEntry() { + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById(RESOLVED_CLIENT_ENTRY); + if (mod) env.moduleGraph.invalidateModule(mod); } + } + + function invalidateHybridClientEntries() { + if (!hasAppDir || !hasPagesDir) return; + invalidatePagesClientEntry(); + invalidateAppBrowserEntry(); server.ws.send({ type: "full-reload" }); } @@ -4437,6 +4474,8 @@ export const loadServerActionClient = ${ invalidateMetadataFileCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + invalidateAppBrowserEntry(); + if (hasPagesDir) invalidatePagesClientEntry(); } let hybridRouteValidation: Promise = Promise.resolve(); @@ -4596,10 +4635,53 @@ export const loadServerActionClient = ${ if (hasAppDir) invalidateRscEntryModule(); if (hasCloudflarePlugin && hasPagesDir && !hasAppDir) invalidatePagesServerEntry(); }; + const isTrackedInstantConfigDependency = (filePath: string): boolean => + instantConfigDependencies.has(toSlash(filePath)); + const routeModuleNowExportsInstantConfig = (filePath: string): boolean => { + const canonicalPath = toSlash(filePath); + if ( + !canonicalPath.startsWith(`${appDir}/`) || + (!fileMatcher.isAppRouterPage(filePath) && !fileMatcher.isAppLayoutFile(filePath)) + ) { + return false; + } + try { + return analyzeNamedExportObjectStringProperty( + fs.readFileSync(filePath, "utf8"), + "unstable_instant", + "prefetch", + ).hasExport; + } catch { + return false; + } + }; + let instantMetadataRefresh: Promise = Promise.resolve(); + const refreshInstantMetadataAfterChange = (): void => { + instantMetadataRefresh = instantMetadataRefresh + .catch(() => {}) + .then(async () => { + const previousSignature = instantRouteMetadataSignature; + invalidateAppRouteCache(); + const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); + const nextSignature = readInstantRouteMetadataSignature(graph); + captureInstantRouteMetadata(graph); + if (previousSignature !== nextSignature) { + invalidateAppRoutingModules(); + } + }) + .catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + server.ws.send({ + type: "error", + err: { message: err.message, stack: err.stack ?? err.message }, + }); + }); + }; server.watcher.on("add", (filePath: string) => { updatePublicFileRoute(filePath, true); let routeChanged = false; + const instantConfigChanged = hasAppDir && isTrackedInstantConfigDependency(filePath); const pagesAppChanged = isPagesAppFile(filePath); const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath); if ( @@ -4621,6 +4703,8 @@ export const loadServerActionClient = ${ invalidateAppRoutingModules(); regenerateAppRouteTypes(); routeChanged = true; + } else if (instantConfigChanged) { + invalidateAppRoutingModules(); } if (routeChanged) { invalidatePagesServerEntry(); @@ -4638,10 +4722,23 @@ export const loadServerActionClient = ${ ) { invalidatePagesClientAssetsModule(); } + if ( + hasAppDir && + (isTrackedInstantConfigDependency(filePath) || + routeModuleNowExportsInstantConfig(filePath)) + ) { + // Route metadata such as `unstable_instant` is content-derived and + // may also be supplied by a local re-export. Re-scan it first so + // ordinary component edits keep their normal fine-grained HMR; + // rebuild the virtual route entries only when the derived metadata + // actually changed. + refreshInstantMetadataAfterChange(); + } }); server.watcher.on("unlink", (filePath: string) => { updatePublicFileRoute(filePath, false); let routeChanged = false; + const instantConfigChanged = hasAppDir && isTrackedInstantConfigDependency(filePath); const pagesAppChanged = isPagesAppFile(filePath); const pagesAssetGraphScriptChanged = isPotentialPagesAssetGraphScript(filePath); if ( @@ -4663,6 +4760,8 @@ export const loadServerActionClient = ${ invalidateAppRoutingModules(); regenerateAppRouteTypes(); routeChanged = true; + } else if (instantConfigChanged) { + invalidateAppRoutingModules(); } if (routeChanged) { invalidatePagesServerEntry(); diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index a9236e608f..a6b400d99c 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -11,6 +11,11 @@ import { decodeRouteSegment, isInvisibleSegment, sortRoutes } from "./utils.js"; import { findFileWithExts, scanWithExtensions, type ValidFileMatcher } from "./file-matcher.js"; import { validateRoutePatterns } from "./route-validation.js"; import { compareStrings } from "../utils/compare.js"; +import { isUnknownRecord as isRecord } from "../utils/record.js"; +import { + analyzeNamedExportObjectStringProperty, + type NamedExportObjectStringPropertyAnalysis, +} from "../build/report.js"; type InterceptingRoute = { /** Graph-owned identity for this interception edge. */ @@ -231,6 +236,14 @@ export type AppRoute = { layoutTreePositions: number[]; /** Whether this is a dynamic route */ isDynamic: boolean; + /** Whether the page or an active ancestor/slot layout enables runtime instant prefetching. */ + hasRuntimeInstant?: boolean; + /** Whether the page or an active ancestor/slot layout enables any instant prefetch mode. */ + hasInstant?: boolean; + /** Whether any active page/layout module exports unstable_instant, including false/invalid values. */ + hasInstantConfig?: boolean; + /** Whether unstable_instant is exported from an active Client Component module. */ + hasInstantConfigInClientModule?: boolean; /** Parameter names for dynamic segments */ params: string[]; /** Dynamic parameter names captured by the route's root layout. */ @@ -239,6 +252,311 @@ export type AppRoute = { patternParts: string[]; }; +const ROUTE_CONFIG_MODULE_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mts", + ".mjs", + ".cts", + ".cjs", +]; +function localRouteConfigModuleCandidates(importer: string, source: string): string[] { + if (!source.startsWith(".")) return []; + + const base = path.resolve(path.dirname(importer), source); + const candidates = [ + base, + ...ROUTE_CONFIG_MODULE_EXTENSIONS.map((extension) => `${base}${extension}`), + ...ROUTE_CONFIG_MODULE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)), + ]; + const sourceExtension = path.extname(base); + if ([".js", ".jsx", ".mjs", ".cjs"].includes(sourceExtension)) { + const withoutExtension = base.slice(0, -sourceExtension.length); + candidates.push( + ...[".ts", ".tsx", ".mts", ".cts"].map((extension) => `${withoutExtension}${extension}`), + ); + } + + return candidates; +} + +function resolveLocalRouteConfigModule(candidates: readonly string[]): string | null { + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + // Try the next source-resolution candidate. + } + } + return null; +} + +type InstantPrefetchMode = "runtime" | "static"; +type InstantModuleAnalysisCache = { + analyses: Map; + sources: Map; +}; + +const INSTANT_CONFIG_KEYS = new Set([ + "prefetch", + "samples", + "from", + "unstable_disableValidation", + "unstable_disableDevValidation", + "unstable_disableBuildValidation", +]); +const INSTANT_SAMPLE_KEYS = new Set(["cookies", "headers", "params", "searchParams"]); +const INSTANT_COOKIE_KEYS = new Set(["name", "value"]); + +function hasOnlyKeys(value: Record, keys: ReadonlySet): boolean { + return Object.keys(value).every((key) => keys.has(key)); +} + +function isStringOrStringArray(value: unknown): boolean { + return ( + typeof value === "string" || + (Array.isArray(value) && value.every((item) => typeof item === "string")) + ); +} + +function isInstantSample(value: unknown): boolean { + if (!isRecord(value) || !hasOnlyKeys(value, INSTANT_SAMPLE_KEYS)) return false; + + if ( + value.cookies !== undefined && + (!Array.isArray(value.cookies) || + !value.cookies.every( + (cookie) => + isRecord(cookie) && + hasOnlyKeys(cookie, INSTANT_COOKIE_KEYS) && + typeof cookie.name === "string" && + (typeof cookie.value === "string" || cookie.value === null), + )) + ) { + return false; + } + + if ( + value.headers !== undefined && + (!Array.isArray(value.headers) || + !value.headers.every( + (header) => + Array.isArray(header) && + header.length === 2 && + typeof header[0] === "string" && + (typeof header[1] === "string" || header[1] === null), + )) + ) { + return false; + } + + if ( + value.params !== undefined && + (!isRecord(value.params) || !Object.values(value.params).every(isStringOrStringArray)) + ) { + return false; + } + + if ( + value.searchParams !== undefined && + (!isRecord(value.searchParams) || + !Object.values(value.searchParams).every( + (entry) => entry === null || isStringOrStringArray(entry), + )) + ) { + return false; + } + + return true; +} + +function isValidInstantConfig(value: unknown): boolean { + if (value === false) return true; + if (!isRecord(value) || !hasOnlyKeys(value, INSTANT_CONFIG_KEYS)) return false; + if (value.prefetch !== "static" && value.prefetch !== "runtime") return false; + + if ( + value.samples !== undefined && + (!Array.isArray(value.samples) || + value.samples.length === 0 || + !value.samples.every(isInstantSample)) + ) { + return false; + } + if (value.prefetch === "runtime" && value.samples === undefined) return false; + if ( + value.from !== undefined && + (!Array.isArray(value.from) || !value.from.every((item) => typeof item === "string")) + ) { + return false; + } + for (const key of [ + "unstable_disableValidation", + "unstable_disableDevValidation", + "unstable_disableBuildValidation", + ]) { + if (value[key] !== undefined && value[key] !== true) return false; + } + return true; +} + +function assertValidInstantConfig(value: unknown, routePattern: string): void { + if (isValidInstantConfig(value)) return; + throw new Error( + `Invalid unstable_instant value ${JSON.stringify(value)} on "${routePattern}", must be an object with \`prefetch: "static"\` or \`prefetch: "runtime"\`, or \`false\`. Read more at https://nextjs.org/docs/messages/invalid-instant-configuration`, + ); +} + +function readInstantModuleAnalysis( + filePath: string, + exportName: string, + cache: InstantModuleAnalysisCache, +): NamedExportObjectStringPropertyAnalysis | null { + const key = `${filePath}\0${exportName}`; + const cached = cache.analyses.get(key); + if (cached !== undefined) return cached; + + let source = cache.sources.get(filePath); + if (source === undefined) { + try { + source = fs.readFileSync(filePath, "utf8"); + } catch { + source = null; + } + cache.sources.set(filePath, source); + } + if (source === null) { + cache.analyses.set(key, null); + return null; + } + + try { + const analysis = analyzeNamedExportObjectStringProperty(source, exportName, "prefetch"); + cache.analyses.set(key, analysis); + return analysis; + } catch { + cache.analyses.set(key, null); + return null; + } +} + +function routeModuleExportInstantPrefetchMode( + filePath: string, + exportName: string, + routePattern: string, + visited: Set, + dependencies: Set, + analysisCache: InstantModuleAnalysisCache, +): InstantPrefetchMode | null { + const visitKey = `${filePath}\0${exportName}`; + if (visited.has(visitKey)) return null; + visited.add(visitKey); + + const analysis = readInstantModuleAnalysis(filePath, exportName, analysisCache); + if (analysis === null) return null; + if (analysis.hasExport) dependencies.add(toSlash(filePath)); + if (analysis.hasExport && !analysis.hasStaticValue && analysis.reexport === null) { + throw new Error( + `Invalid unstable_instant value on "${routePattern}": the exported configuration must be statically analyzable. Read more at https://nextjs.org/docs/messages/invalid-instant-configuration`, + ); + } + if (analysis.hasStaticValue) assertValidInstantConfig(analysis.staticValue, routePattern); + if (analysis.propertyValue === "runtime" || analysis.propertyValue === "static") { + return analysis.propertyValue; + } + + const reexport = analysis.reexport; + if (reexport === null) return null; + const candidates = localRouteConfigModuleCandidates(filePath, reexport.source); + for (const candidate of candidates) dependencies.add(toSlash(candidate)); + const reexportPath = resolveLocalRouteConfigModule(candidates); + if (reexportPath === null) { + // Vite aliases and package exports require the configured resolver, which + // is not available to this synchronous graph scan. Conservatively treat + // unresolved named re-exports as runtime instant so a dynamic response is + // never promoted into the reusable full-prefetch cache. + return "runtime"; + } + return routeModuleExportInstantPrefetchMode( + reexportPath, + reexport.importedName, + routePattern, + visited, + dependencies, + analysisCache, + ); +} + +function routeModuleInstantPrefetchMode( + filePath: string | null, + routePattern: string, + dependencies: Set, + analysisCache: InstantModuleAnalysisCache, +): InstantPrefetchMode | null { + return filePath === null + ? null + : routeModuleExportInstantPrefetchMode( + filePath, + "unstable_instant", + routePattern, + new Set(), + dependencies, + analysisCache, + ); +} + +function routeInstantConfigMetadata( + route: AppRoute, + dependencies: Set, + analysisCache: InstantModuleAnalysisCache, +): { + hasConfig: boolean; + hasConfigInClientModule: boolean; + mode: InstantPrefetchMode | null; +} { + const modulePaths = [ + route.pagePath, + ...route.layouts, + ...route.parallelSlots.flatMap((slot) => [ + slot.pagePath, + slot.layoutPath ?? null, + ...(slot.configLayoutPaths ?? []), + ]), + ]; + let mode: InstantPrefetchMode | null = null; + let hasConfig = false; + let hasConfigInClientModule = false; + for (const modulePath of modulePaths) { + if (modulePath === null) continue; + const analysis = readInstantModuleAnalysis(modulePath, "unstable_instant", analysisCache); + if (analysis?.hasExport) { + const dynamicStaleTimeAnalysis = readInstantModuleAnalysis( + modulePath, + "unstable_dynamicStaleTime", + analysisCache, + ); + if (dynamicStaleTimeAnalysis?.hasExport) { + throw new Error( + `Page "${route.pattern}" cannot use both \`export const unstable_dynamicStaleTime\` and \`export const unstable_instant\`.`, + ); + } + hasConfig = true; + if (analysis.hasUseClientDirective) hasConfigInClientModule = true; + } + const moduleMode = routeModuleInstantPrefetchMode( + modulePath, + route.pattern, + dependencies, + analysisCache, + ); + if (moduleMode === "runtime") mode = "runtime"; + else if (moduleMode === "static" && mode === null) mode = "static"; + } + return { hasConfig, hasConfigInClientModule, mode }; +} + export type AppRouteSemanticIds = { route: string; page: string | null; @@ -944,7 +1262,17 @@ function createRouteManifestGraphVersion(segmentGraph: StaticSegmentGraph): Grap export async function buildAppRouteGraph( appDir: string, matcher: ValidFileMatcher, -): Promise<{ routes: AppRouteGraphRoute[]; routeManifest: RouteManifest }> { +): Promise<{ + routes: AppRouteGraphRoute[]; + routeManifest: RouteManifest; + instantConfigDependencies: Set; +}> { + const instantConfigDependencies = new Set(); + const instantModuleAnalysisCache: InstantModuleAnalysisCache = { + analyses: new Map(), + sources: new Map(), + }; + // Find all page.tsx and route.ts files, excluding @slot directories // (slot pages are not standalone routes — they're rendered as props of their parent layout) // and _private folders (Next.js convention for colocated non-route files). @@ -1074,10 +1402,25 @@ export async function buildAppRouteGraph( ]; validateRoutePatterns(interceptTargetPatterns); + // Compute this after slot sub-routes and intercept metadata are finalized so + // inherited layout and active parallel-slot configs propagate to every + // concrete route advertised to the browser prefetch policy. + for (const route of routes) { + const instant = routeInstantConfigMetadata( + route, + instantConfigDependencies, + instantModuleAnalysisCache, + ); + route.hasInstant = instant.mode !== null; + route.hasRuntimeInstant = instant.mode === "runtime"; + route.hasInstantConfig = instant.hasConfig; + route.hasInstantConfigInClientModule = instant.hasConfigInClientModule; + } + // Sort: static routes first, then dynamic, then catch-all sortRoutes(routes); - return { routes, routeManifest: createRouteManifest(routes) }; + return { routes, routeManifest: createRouteManifest(routes), instantConfigDependencies }; } function hasParallelSlotDirectory(dir: string): boolean { diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index f567994c29..97e2f50ee3 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -29,6 +29,7 @@ export { } from "./app-route-graph.js"; type AppRouteGraph = { + instantConfigDependencies: Set; routes: AppRouteGraphRoute[]; routeManifest: RouteManifest; }; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f5be6d0cd6..57656dd2c3 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -22,6 +22,7 @@ import { notifyAppRouterTransitionStart } from "../client/instrumentation-client import { __basePath, appRouterInstance, + attachPrefetchInvalidationCallback, commitClientNavigationState, consumePrefetchResponse, consumePrefetchResponseForNavigation, @@ -34,6 +35,8 @@ import { getBfcacheIdMapContext, getMountedSlotsHeader, getPrefetchCache, + getLiveRetainedPrefetchLayoutClaims, + getRetainedPrefetchLayoutClaims, hasPrefetchCacheEntryForNavigation, invalidatePrefetchCache, preloadHybridClientRouteOwner, @@ -55,6 +58,7 @@ import { type CachedRscResponse, type ClientNavigationRenderSnapshot, type PrefetchCacheEntry, + type RetainedPrefetchLayoutClaim, } from "vinext/shims/navigation"; import { getNavigationRuntime, @@ -177,6 +181,8 @@ import { } from "./app-browser-prefetch-response.js"; import { createOptimisticRouteTemplate, + fillRetainedPrefetchLayoutsFromElementSources, + getMissingRetainedPrefetchLayoutIds, getOptimisticPrefetchSourceKey, getOptimisticRouteTemplateKey, matchOptimisticRouteManifestRoute, @@ -478,28 +484,51 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { } if (options.interceptionContext !== null) return false; - const elements = await decodeAppElementsPromise( - createFromFetch(Promise.resolve(restoreRscResponse(options.entry.snapshot))), + const elements = await fillSkippedLayoutsFromPrefetchCache( + decodeAppElementsPromise( + createFromFetch(Promise.resolve(restoreRscResponse(options.entry.snapshot))), + ), + options.entry.retainedLayoutClaims, ); const template = createOptimisticRouteTemplate({ - allowLoadingShell: options.entry.optimisticRouteShell === true, + allowLoadingShell: + options.entry.optimisticRouteShell === true || options.entry.instantShell === true, basePath: __basePath, elements, href: options.entry.snapshot.url || source.rscUrl, interceptionContext: options.interceptionContext, mountedSlotsHeader: options.mountedSlotsHeader, + preservePageElements: options.entry.instantShell === true, routeManifest: options.routeManifest, }); if (template === null) return false; - optimisticRouteTemplates.set( - getOptimisticRouteTemplateKey({ - interceptionContext: options.interceptionContext, - mountedSlotsHeader: options.mountedSlotsHeader, - routeId: template.routeId, - }), - template, - ); + const templateKey = getOptimisticRouteTemplateKey({ + concreteHrefKey: template.concreteHrefKey, + interceptionContext: options.interceptionContext, + mountedSlotsHeader: options.mountedSlotsHeader, + routeId: template.routeId, + }); + const sourceKey = getOptimisticPrefetchSourceKey({ + cacheKey: options.cacheKey, + interceptionContext: options.interceptionContext, + mountedSlotsHeader: options.mountedSlotsHeader, + }); + if (options.entry.instantShell === true) { + const attached = attachPrefetchInvalidationCallback( + options.cacheKey, + () => { + if (optimisticRouteTemplates.get(templateKey) === template) { + optimisticRouteTemplates.delete(templateKey); + } + optimisticRouteTemplateSources.delete(sourceKey); + }, + options.entry, + ); + if (!attached) return false; + } + optimisticRouteTemplates.set(templateKey, template); + optimisticRouteTemplateSources.add(sourceKey); return true; } @@ -529,9 +558,7 @@ async function learnOptimisticRouteTemplatesFromPrefetchCache(options: { mountedSlotsHeader: options.mountedSlotsHeader, routeManifest: options.routeManifest, }) - .then((learned) => { - if (learned) optimisticRouteTemplateSources.add(sourceKey); - }) + .then(() => {}) .finally(() => { optimisticRouteTemplateLearning.delete(sourceKey); }); @@ -998,6 +1025,46 @@ function decodeAppElementsPromise(payload: Promise): Promise AppElementsWire.decode(elements)); } +async function decodeRetainedLayoutProviderElements( + entry: PrefetchCacheEntry, +): Promise { + if (entry.preparedElements) return entry.preparedElements; + if ( + entry.retainedLayoutProvider !== true || + entry.cacheForNavigation === false || + !entry.snapshot + ) { + return null; + } + try { + const elements = await decodeAppElementsPromise( + createFromFetch(Promise.resolve(restoreRscResponse(entry.snapshot))), + ); + entry.preparedElements = elements; + return elements; + } catch { + return null; + } +} + +async function fillSkippedLayoutsFromPrefetchCache( + payload: Promise | AppElements, + retainedLayoutClaims: readonly RetainedPrefetchLayoutClaim[] | undefined, +): Promise { + const elements = await payload; + const missing = new Set(getMissingRetainedPrefetchLayoutIds(elements)); + if (missing.size === 0) return elements; + + const sources: AppElements[] = []; + for (const claim of getLiveRetainedPrefetchLayoutClaims(retainedLayoutClaims, missing)) { + const sourceElements = await decodeRetainedLayoutProviderElements(claim.provider); + if (!sourceElements) continue; + sources.push(sourceElements); + if (Object.hasOwn(sourceElements, claim.layoutId)) missing.delete(claim.layoutId); + } + return fillRetainedPrefetchLayoutsFromElementSources(elements, sources); +} + function BrowserRoot({ hydrationCachePublication, initialElements, @@ -1776,6 +1843,15 @@ function bootstrapHydration( const shouldBypassNavigationCache = earlyIntentDecision?.kind === "flightNavigation" && earlyIntentDecision.bypassNavigationCache; + const retainedLayoutClaims = getRetainedPrefetchLayoutClaims({ + interceptionContext: requestInterceptionContext, + mountedSlotsHeader, + targetHref: currentHref, + }); + const retainedPrefetchLayoutsHeader = + retainedLayoutClaims.length === 0 + ? null + : retainedLayoutClaims.map((claim) => claim.layoutId).join(" "); // 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 @@ -1785,6 +1861,7 @@ function bootstrapHydration( fetchPriority: "auto", interceptionContext: requestInterceptionContext, mountedSlotsHeader, + retainedPrefetchLayoutsHeader, }); const rewrittenNavigationHref = navigationKind === "navigate" && HAS_CLIENT_REWRITES @@ -1929,10 +2006,13 @@ function bootstrapHydration( ); const cachedPayload = cachedRoute.elements ? Promise.resolve(cachedRoute.elements) - : decodeAppElementsPromise( - createFromFetch( - Promise.resolve(restoreRscResponse(cachedRoute.response)), + : fillSkippedLayoutsFromPrefetchCache( + decodeAppElementsPromise( + createFromFetch( + Promise.resolve(restoreRscResponse(cachedRoute.response)), + ), ), + cachedRoute.response.retainedLayoutClaims, ); if (!browserNavigationController.isCurrentNavigation(navId)) return; const cachedRenderOutcome = await renderNavigationPayload({ @@ -2214,8 +2294,11 @@ function bootstrapHydration( } const rscPayload = prefetchedElements ? prefetchedElements - : decodeAppElementsPromise( - createFromFetch(Promise.resolve(reactResponse)), + : fillSkippedLayoutsFromPrefetchCache( + decodeAppElementsPromise( + createFromFetch(Promise.resolve(reactResponse)), + ), + retainedLayoutClaims, ); if (!browserNavigationController.isCurrentNavigation(navId)) return; @@ -2418,8 +2501,11 @@ function bootstrapHydration( }; }, navigate: navigateRsc, - preparePrefetchResponse: (response) => - decodeAppElementsPromise(createFromFetch(Promise.resolve(response))), + preparePrefetchResponse: (response, retainedLayoutClaims) => + fillSkippedLayoutsFromPrefetchCache( + decodeAppElementsPromise(createFromFetch(Promise.resolve(response))), + retainedLayoutClaims as readonly RetainedPrefetchLayoutClaim[] | undefined, + ), }); // Note: This popstate handler runs for App Router (RSC navigation available). diff --git a/packages/vinext/src/server/app-elements-wire.ts b/packages/vinext/src/server/app-elements-wire.ts index a2a18b6ee4..219455550e 100644 --- a/packages/vinext/src/server/app-elements-wire.ts +++ b/packages/vinext/src/server/app-elements-wire.ts @@ -29,6 +29,7 @@ export const APP_LAYOUT_IDS_KEY = "__layoutIds"; export const APP_LAYOUT_FLAGS_KEY = "__layoutFlags"; export const APP_ROUTE_KEY = "__route"; export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; +export const APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY = "__retainedOnlySkippedLayoutIds"; export const APP_SKIPPED_LAYOUT_IDS_KEY = "__skippedLayoutIds"; export const APP_SOURCE_PAGE_SEGMENTS_KEY = "__srcPage"; export const APP_SLOT_BINDINGS_KEY = "__slotBindings"; @@ -207,6 +208,7 @@ type AppElementsMetadata = { routeId: string; rootLayoutTreePath: string | null; bfcacheSegmentIdentities: AppElementsBfcacheSegmentIdentities; + retainedOnlySkippedLayoutIds: readonly string[]; skippedLayoutIds: readonly string[]; slotBindings: readonly AppElementsSlotBinding[]; sourcePage: string | null; @@ -275,6 +277,7 @@ type AppElementsWireKeys = { readonly rootLayout: typeof APP_ROOT_LAYOUT_KEY; readonly route: typeof APP_ROUTE_KEY; readonly bfcacheSegmentIdentities: typeof APP_BFCACHE_SEGMENT_IDENTITIES_KEY; + readonly retainedOnlySkippedLayoutIds: typeof APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY; readonly skippedLayoutIds: typeof APP_SKIPPED_LAYOUT_IDS_KEY; readonly slotBindings: typeof APP_SLOT_BINDINGS_KEY; readonly sourcePageSegments: typeof APP_SOURCE_PAGE_SEGMENTS_KEY; @@ -530,6 +533,10 @@ function parseSkippedLayoutIds(value: unknown): readonly string[] { return parseLayoutIdList(value, APP_SKIPPED_LAYOUT_IDS_KEY); } +function parseRetainedOnlySkippedLayoutIds(value: unknown): readonly string[] { + return parseLayoutIdList(value, APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY); +} + function isSlotBindingState(value: unknown): value is AppElementsSlotBindingState { return value === "active" || value === "default" || value === "unmatched"; } @@ -692,6 +699,7 @@ export function buildOutgoingAppPayload(input: { return input.element; } const skippedLayoutIds = createSkippedLayoutIds(input.skipDisposition); + const retainedOnlySkippedLayoutIds = createRetainedOnlySkippedLayoutIds(input.skipDisposition); const payload: Record< string, | ReactNode @@ -715,6 +723,9 @@ export function buildOutgoingAppPayload(input: { if (skippedLayoutIds.size > 0) { payload[APP_SKIPPED_LAYOUT_IDS_KEY] = [...skippedLayoutIds]; } + if (retainedOnlySkippedLayoutIds.size > 0) { + payload[APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY] = [...retainedOnlySkippedLayoutIds]; + } payload[APP_ARTIFACT_COMPATIBILITY_KEY] = input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(); if (input.cacheEntryReuseProof) { @@ -740,6 +751,25 @@ function createSkippedLayoutIds( return skippedLayoutIds; } +function createRetainedOnlySkippedLayoutIds( + skipDisposition: ClientReuseManifestSkipDisposition | undefined, +): ReadonlySet { + if ( + skipDisposition?.enabled !== true || + skipDisposition.code !== "SKIP_RETAINED_PREFETCH_LAYOUTS" + ) { + return EMPTY_SKIPPED_LAYOUT_IDS; + } + + const retainedOnlySkippedLayoutIds = new Set(); + for (const id of skipDisposition.retainedOnlySkippedEntryIds) { + if (parseAppElementsWireElementKey(id)?.kind === "layout") { + retainedOnlySkippedLayoutIds.add(id); + } + } + return retainedOnlySkippedLayoutIds; +} + function readArtifactCompatibilityMetadata(value: unknown): ArtifactCompatibilityEnvelope { if (value === undefined) return createArtifactCompatibilityEnvelope(); @@ -878,6 +908,15 @@ export function readAppElementsMetadata( const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); const layoutIds = parseLayoutIds(elements[APP_LAYOUT_IDS_KEY]); const skippedLayoutIds = parseSkippedLayoutIds(elements[APP_SKIPPED_LAYOUT_IDS_KEY]); + const retainedOnlySkippedLayoutIds = parseRetainedOnlySkippedLayoutIds( + elements[APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY], + ); + const skippedLayoutIdSet = new Set(skippedLayoutIds); + if (retainedOnlySkippedLayoutIds.some((layoutId) => !skippedLayoutIdSet.has(layoutId))) { + throw new Error( + "[vinext] Invalid __retainedOnlySkippedLayoutIds in App Router payload: expected subset of __skippedLayoutIds", + ); + } const slotBindings = parseSlotBindings(elements[APP_SLOT_BINDINGS_KEY], { layoutIds }); const interception = parseInterceptionMetadata(elements[APP_INTERCEPTION_KEY]); const artifactCompatibility = readArtifactCompatibilityMetadata( @@ -914,6 +953,7 @@ export function readAppElementsMetadata( routeId, rootLayoutTreePath, bfcacheSegmentIdentities, + retainedOnlySkippedLayoutIds, skippedLayoutIds, slotBindings, sourcePage, @@ -934,6 +974,7 @@ export const AppElementsWire: AppElementsWireCodec = { rootLayout: APP_ROOT_LAYOUT_KEY, route: APP_ROUTE_KEY, bfcacheSegmentIdentities: APP_BFCACHE_SEGMENT_IDENTITIES_KEY, + retainedOnlySkippedLayoutIds: APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY, skippedLayoutIds: APP_SKIPPED_LAYOUT_IDS_KEY, slotBindings: APP_SLOT_BINDINGS_KEY, sourcePageSegments: APP_SOURCE_PAGE_SEGMENTS_KEY, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 3bb2469918..32401b2ce6 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -13,6 +13,7 @@ export { APP_INTERCEPTION_CONTEXT_KEY, APP_LAYOUT_IDS_KEY, APP_LAYOUT_FLAGS_KEY, + APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_SKIPPED_LAYOUT_IDS_KEY, diff --git a/packages/vinext/src/server/app-instant-prefetch-segments.tsx b/packages/vinext/src/server/app-instant-prefetch-segments.tsx new file mode 100644 index 0000000000..7e69315223 --- /dev/null +++ b/packages/vinext/src/server/app-instant-prefetch-segments.tsx @@ -0,0 +1,116 @@ +import { + cloneElement, + createElement, + isValidElement, + type ComponentType, + type ReactElement, + type ReactNode, +} from "react"; +import { runWithInstantPrefetchShellStage } from "vinext/shims/instant-prefetch-shell"; +import { invokeAppComponent, isReactOwnedAppComponent } from "./app-render-dependency.js"; +import { isPromiseLike } from "../utils/promise.js"; + +export type AppInstantPrefetchSegmentStage = "runtime" | "static"; + +type AppInstantPrefetchModule = Readonly> | null | undefined; + +type InstantConfig = Readonly<{ + prefetch?: unknown; +}>; + +export type AppInstantPrefetchSegmentStages = Readonly<{ + layouts: readonly AppInstantPrefetchSegmentStage[]; + page: AppInstantPrefetchSegmentStage; +}>; + +function isRuntimeInstantModule(module: AppInstantPrefetchModule): boolean { + const config = module?.unstable_instant as InstantConfig | null | undefined; + return typeof config === "object" && config !== null && config.prefetch === "runtime"; +} + +export function resolveAppInstantPrefetchSegmentStage( + module: AppInstantPrefetchModule, + inheritedStage: AppInstantPrefetchSegmentStage, +): AppInstantPrefetchSegmentStage { + return isRuntimeInstantModule(module) ? "runtime" : inheritedStage; +} + +/** + * Resolve the stage for each segment in the combined Vinext response. + * + * Next starts a combined runtime request at the first new segment with a + * runtime hint. All lower segments join that request, even when they default + * to static. A retained runtime layout is still in the shared part of the + * tree, so it must not make the newly requested child subtree runtime. + * + * @see https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/client/components/segment-cache/scheduler.ts + */ +export function resolveAppInstantPrefetchSegmentStages(options: { + layouts: readonly AppInstantPrefetchModule[]; + page: AppInstantPrefetchModule; + retainedLayouts?: ReadonlySet; +}): AppInstantPrefetchSegmentStages { + let activeStage: AppInstantPrefetchSegmentStage = "static"; + const layouts = options.layouts.map((module, index) => { + if (!options.retainedLayouts?.has(index)) { + activeStage = resolveAppInstantPrefetchSegmentStage(module, activeStage); + } + return activeStage; + }); + + activeStage = resolveAppInstantPrefetchSegmentStage(options.page, activeStage); + + return { layouts, page: activeStage }; +} + +type SegmentBoundaryProps = Readonly<{ + component: ComponentType>; + componentProps: Readonly>; + stage: AppInstantPrefetchSegmentStage; +}>; + +function wrapSegmentSubtree(value: ReactNode, stage: AppInstantPrefetchSegmentStage): ReactNode { + if (Array.isArray(value)) { + return value.map((child) => wrapSegmentSubtree(child, stage)); + } + if (!isValidElement(value)) return value; + + const element = value as ReactElement>; + if (!isReactOwnedAppComponent(element.type)) { + return createElement(InstantPrefetchSegmentBoundary, { + key: element.key, + component: element.type as ComponentType>, + componentProps: element.props, + stage, + }); + } + + if (!("children" in element.props)) return element; + return cloneElement( + element, + undefined, + wrapSegmentSubtree(element.props.children as ReactNode, stage), + ); +} + +function InstantPrefetchSegmentBoundary({ + component, + componentProps, + stage, +}: SegmentBoundaryProps): ReactNode | Promise { + return runWithInstantPrefetchShellStage(stage, () => { + const result = invokeAppComponent(component, componentProps); + return isPromiseLike(result) + ? Promise.resolve(result).then((resolved) => wrapSegmentSubtree(resolved, stage)) + : wrapSegmentSubtree(result, stage); + }); +} + +InstantPrefetchSegmentBoundary.displayName = "Vinext.InstantPrefetchSegmentBoundary"; + +export function wrapAppInstantPrefetchSegment( + element: ReactNode, + stage: AppInstantPrefetchSegmentStage, +): ReactNode { + return wrapSegmentSubtree(element, stage); +} diff --git a/packages/vinext/src/server/app-optimistic-routing.ts b/packages/vinext/src/server/app-optimistic-routing.ts index c1f04f5e66..febdb9a5aa 100644 --- a/packages/vinext/src/server/app-optimistic-routing.ts +++ b/packages/vinext/src/server/app-optimistic-routing.ts @@ -1,4 +1,4 @@ -import { createElement, isValidElement, Suspense } from "react"; +import { cloneElement, createElement, isValidElement, Suspense, type ReactNode } from "react"; import { isUnknownRecord } from "../utils/record.js"; import { stripBasePath } from "../utils/base-path.js"; import { buildParams, decodeMatchedParams, splitPathnameForRouteMatch } from "../routing/utils.js"; @@ -26,6 +26,7 @@ type OptimisticRouteMatch = { }; export type OptimisticRouteTemplate = { + concreteHrefKey: string | null; elements: AppElements; mountedSlotsHeader: string | null; pageElementIds: readonly string[]; @@ -44,11 +45,12 @@ const routeTrieCache = new WeakMap(); const OPTIMISTIC_ROUTE_SEGMENT_SUSPENSE_TRIGGER = new Promise(() => {}); export function getOptimisticRouteTemplateKey(options: { + concreteHrefKey?: string | null; interceptionContext: string | null; mountedSlotsHeader: string | null; routeId: string; }): string { - return `${options.routeId}\0${options.interceptionContext ?? ""}\0${options.mountedSlotsHeader ?? ""}`; + return `${options.routeId}\0${options.interceptionContext ?? ""}\0${options.mountedSlotsHeader ?? ""}\0${options.concreteHrefKey ?? ""}`; } export function getOptimisticPrefetchSourceKey(options: { @@ -198,6 +200,18 @@ function hrefToRouteParts(href: string, basePath: string): string[] | null { return splitPathnameForRouteMatch(appPathname === "" ? "/" : appPathname); } +function getConcreteOptimisticHrefKey(href: string): string | null { + let url: URL; + try { + url = new URL(href, "https://vinext.local"); + } catch { + return null; + } + stripRscCacheBustingSearchParam(url); + url.pathname = stripRscSuffix(url.pathname); + return `${url.pathname}${url.search}`; +} + export function matchOptimisticRouteManifestRoute(options: { basePath: string; href: string; @@ -300,6 +314,54 @@ function OptimisticRouteSegment(): null { throw OPTIMISTIC_ROUTE_SEGMENT_SUSPENSE_TRIGGER; } +const REACT_LAZY_TYPE = Symbol.for("react.lazy"); +const SUSPENDED_LAZY_STATUSES = new Set(["pending", "blocked", "halted", "rejected"]); + +function sanitizeInstantShellValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeInstantShellValue); + + if (isUnknownRecord(value) && value.$$typeof === REACT_LAZY_TYPE) { + const payload = value._payload; + const initialize = value._init; + if ( + isUnknownRecord(payload) && + typeof payload.status === "string" && + SUSPENDED_LAZY_STATUSES.has(payload.status) + ) { + return createElement(OptimisticRouteSegment); + } + if (typeof initialize === "function") { + try { + return sanitizeInstantShellValue(Reflect.apply(initialize, undefined, [payload])); + } catch (error) { + if (error === payload || (isUnknownRecord(error) && typeof error.then === "function")) { + return createElement(OptimisticRouteSegment); + } + throw error; + } + } + } + + if (!isValidElement(value) || !isUnknownRecord(value.props)) return value; + const props = Object.fromEntries( + Object.entries(value.props).map(([name, propValue]) => [ + name, + sanitizeInstantShellValue(propValue), + ]), + ); + return cloneElement(value, props as Record); +} + +function sanitizeInstantShellElements(elements: AppElements): AppElements { + const sanitized: Record = { ...elements }; + for (const [elementId, value] of Object.entries(elements)) { + if (AppElementsWire.parseElementKey(elementId) !== null) { + sanitized[elementId] = sanitizeInstantShellValue(value) as AppElementValue; + } + } + return sanitized; +} + export function createOptimisticRouteTemplate(options: { allowLoadingShell?: boolean; basePath: string; @@ -307,6 +369,7 @@ export function createOptimisticRouteTemplate(options: { href: string; interceptionContext: string | null; mountedSlotsHeader: string | null; + preservePageElements?: boolean; routeManifest: RouteManifest; }): OptimisticRouteTemplate | null { const match = matchOptimisticRouteManifestRoute({ @@ -328,6 +391,7 @@ export function createOptimisticRouteTemplate(options: { if (!options.allowLoadingShell && !elementHasSuspenseFallback(routeElement)) return null; if ( options.allowLoadingShell && + options.preservePageElements !== true && options.elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY] !== "LoadingBoundary" ) { return null; @@ -341,9 +405,14 @@ export function createOptimisticRouteTemplate(options: { if (pageElementIds.length === 0) return null; return { - elements: options.elements, + concreteHrefKey: options.preservePageElements + ? getConcreteOptimisticHrefKey(options.href) + : null, + elements: options.preservePageElements + ? sanitizeInstantShellElements(options.elements) + : options.elements, mountedSlotsHeader: options.mountedSlotsHeader, - pageElementIds, + pageElementIds: options.preservePageElements ? [] : pageElementIds, routeId: match.route.id, }; } @@ -356,6 +425,50 @@ export function createOptimisticRouteElements(template: OptimisticRouteTemplate) return elements; } +export function fillRetainedPrefetchLayoutsFromElementSources( + elements: AppElements, + sources: Iterable, +): AppElements { + const metadata = AppElementsWire.readMetadata(elements); + const missingLayoutIds = metadata.retainedOnlySkippedLayoutIds.filter( + (layoutId) => !Object.hasOwn(elements, layoutId), + ); + if (missingLayoutIds.length === 0) return elements; + + const missing = new Set(missingLayoutIds); + const merged: Record = { ...elements }; + for (const sourceElements of sources) { + if (missing.size === 0) break; + if (!sourceElements) continue; + for (const layoutId of missing) { + if (!Object.hasOwn(sourceElements, layoutId)) continue; + merged[layoutId] = sourceElements[layoutId]; + missing.delete(layoutId); + } + } + + if (missing.size > 0) { + throw new Error("Retained prefetch layout provider is no longer available"); + } + const retainedOnlySkippedLayoutIds = new Set(metadata.retainedOnlySkippedLayoutIds); + const remainingSkippedLayoutIds = metadata.skippedLayoutIds.filter( + (layoutId) => !retainedOnlySkippedLayoutIds.has(layoutId), + ); + if (remainingSkippedLayoutIds.length > 0) { + merged[AppElementsWire.keys.skippedLayoutIds] = remainingSkippedLayoutIds; + } else { + delete merged[AppElementsWire.keys.skippedLayoutIds]; + } + delete merged[AppElementsWire.keys.retainedOnlySkippedLayoutIds]; + return merged as AppElements; +} + +export function getMissingRetainedPrefetchLayoutIds(elements: AppElements): readonly string[] { + return AppElementsWire.readMetadata(elements).retainedOnlySkippedLayoutIds.filter( + (layoutId) => !Object.hasOwn(elements, layoutId), + ); +} + export function resolveOptimisticNavigationPayload(options: { basePath: string; href: string; @@ -376,13 +489,23 @@ export function resolveOptimisticNavigationPayload(options: { }); if (match === null) return null; - const template = options.templates.get( + const exactTemplate = options.templates.get( getOptimisticRouteTemplateKey({ + concreteHrefKey: getConcreteOptimisticHrefKey(options.href), interceptionContext: options.interceptionContext, mountedSlotsHeader: options.mountedSlotsHeader, routeId: match.route.id, }), ); + const template = + exactTemplate ?? + options.templates.get( + getOptimisticRouteTemplateKey({ + interceptionContext: options.interceptionContext, + mountedSlotsHeader: options.mountedSlotsHeader, + routeId: match.route.id, + }), + ); if (template === undefined) return null; if (template.mountedSlotsHeader !== options.mountedSlotsHeader) return null; diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index f226467382..0e3195ebd7 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -295,6 +295,7 @@ export type DispatchAppPageOptions = { }, ) => Promise; clientReuseManifest?: ClientReuseManifestParseResult; + retainedPrefetchLayoutIds?: readonly string[]; cleanPathname: string; displayPathname?: string; clearRequestContext: () => void; @@ -1100,6 +1101,7 @@ async function dispatchAppPageInner( }, element: pageBuildResult.element, clientReuseManifest: options.clientReuseManifest, + retainedPrefetchLayoutIds: options.retainedPrefetchLayoutIds, getDraftModeCookieHeader, getFontLinks: options.getFontLinks, getFontPreloads: options.getFontPreloads, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index 311bc8b7d4..84567b33b1 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -33,7 +33,12 @@ import { sanitizeErrorForClient } from "./app-rsc-errors.js"; import { DEFAULT_GLOBAL_ERROR_MODULE } from "./default-global-error-module.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; -import { APP_RSC_RENDER_MODE_NAVIGATION, type AppRscRenderMode } from "./app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_NAVIGATION, + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + type AppRscRenderMode, +} from "./app-rsc-render-mode.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; import { createAppPageRenderIdentity } from "./app-page-render-identity.js"; import { @@ -46,6 +51,7 @@ import { createAppPageRenderDependency, invokeAppComponent, isAppRenderSuspension, + isClientReferenceAppComponent, isReactOwnedAppComponent, renderAfterAppDependencies, type AppPageRenderDependency, @@ -111,6 +117,8 @@ export type AppPagePageRequest = request: Request; /** Normalized x-vinext-mounted-slots header value. */ mountedSlotsHeader: string | null; + /** Layouts supplied by complete cached prefetch responses on the client. */ + retainedPrefetchLayoutIds?: readonly string[]; /** Semantic RSC payload mode for this page render. */ renderMode?: AppRscRenderMode; /** Observe page `searchParams` access for cache-safety classification. */ @@ -216,6 +224,7 @@ export async function buildPageElements< searchParams, isRscRequest, mountedSlotsHeader, + retainedPrefetchLayoutIds, renderMode = APP_RSC_RENDER_MODE_NAVIGATION, observeMetadataSearchParamsAccess = false, observePageSearchParamsAccess = false, @@ -233,6 +242,37 @@ export async function buildPageElements< const effectivePageModule = isSiblingIntercept ? (opts!.interceptPage as AppPageModule | null | undefined) : pageModule; + let instantPrefetchSegments: + | { + stages: import("./app-instant-prefetch-segments.js").AppInstantPrefetchSegmentStages; + resolveStage: typeof import("./app-instant-prefetch-segments.js").resolveAppInstantPrefetchSegmentStage; + wrap: typeof import("./app-instant-prefetch-segments.js").wrapAppInstantPrefetchSegment; + } + | undefined; + if ( + renderMode === APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL || + renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL + ) { + const instantSegments = await import("./app-instant-prefetch-segments.js"); + const retainedLayoutIds = new Set(retainedPrefetchLayoutIds ?? []); + const retainedLayouts = new Set(); + for (let index = 0; index < route.layouts.length; index++) { + const treePosition = route.layoutTreePositions?.[index] ?? 0; + const layoutId = AppElementsWire.encodeLayoutId( + createAppPageTreePath(route.routeSegments, treePosition), + ); + if (retainedLayoutIds.has(layoutId)) retainedLayouts.add(index); + } + instantPrefetchSegments = { + stages: instantSegments.resolveAppInstantPrefetchSegmentStages({ + layouts: route.layouts, + page: isSiblingIntercept ? null : effectivePageModule, + retainedLayouts, + }), + resolveStage: instantSegments.resolveAppInstantPrefetchSegmentStage, + wrap: instantSegments.wrapAppInstantPrefetchSegment, + }; + } // Resolve the component that will actually render. For a sibling intercept // this is the intercepting page's own default export — we deliberately do // NOT fall back to the source route's page component. Silently rendering a @@ -489,7 +529,16 @@ export async function buildPageElements< : null; void streamingMetadataOutlet?.catch(() => null); - const pageProps: Record = { params: makeThenableParams(effectiveParams) }; + const makeComponentParams = (component: AppPageComponent, params: AppPageParams): unknown => + makeThenableParams(params, undefined, { + suspendStaticInstantShell: !( + renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL && + isClientReferenceAppComponent(component) + ), + }); + const pageProps: Record = EffectivePageComponent + ? { params: makeComponentParams(EffectivePageComponent, effectiveParams) } + : {}; const hasRequestSearchParams = Object.keys(pageSearchParams).length > 0; const pageTreePosition = (sourcePageSegments ?? route.routeSegments ?? []).length; const hasPageLoadingBoundary = @@ -515,11 +564,18 @@ export async function buildPageElements< if (isReactOwnedAppComponent(PageComponent)) { const invocationProps = { ...props }; if (searchParams) { - invocationProps.searchParams = observePageSearchParamsAccess - ? makeObservedAppPageSearchParamsThenable(pageSearchParams, { - markDynamic: hasRequestSearchParams, + const isStaticInstantClientReference = + renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL && + isClientReferenceAppComponent(PageComponent); + invocationProps.searchParams = isStaticInstantClientReference + ? makeThenableParams(pageSearchParams, undefined, { + suspendStaticInstantShell: false, }) - : makeThenableParams(pageSearchParams); + : observePageSearchParamsAccess + ? makeObservedAppPageSearchParamsThenable(pageSearchParams, { + markDynamic: hasRequestSearchParams, + }) + : makeThenableParams(pageSearchParams); } return createElement(PageComponent, invocationProps); } @@ -589,10 +645,29 @@ export async function buildPageElements< // slot-based path handles this inside buildSlotOverrides/app-page-route-wiring, // but sibling intercepts bypass that path entirely. We apply the wrapping here // so a layout.tsx adjacent to the (.) / (..) / (...) marker dir is respected. - let siblingInterceptElement: ReturnType | null = + let siblingInterceptElement: React.ReactNode = isSiblingIntercept && EffectivePageComponent ? createPageElement(EffectivePageComponent, pageProps, pageRenderDependency) : null; + const siblingInterceptLayoutStages: ("runtime" | "static")[] = []; + let siblingInterceptPageStage = instantPrefetchSegments?.stages.page ?? "static"; + if (isSiblingIntercept && instantPrefetchSegments) { + let branchStage = instantPrefetchSegments.stages.layouts.at(-1) ?? "static"; + for (const layoutModule of opts?.interceptLayouts ?? []) { + branchStage = instantPrefetchSegments.resolveStage(layoutModule, branchStage); + siblingInterceptLayoutStages.push(branchStage); + } + siblingInterceptPageStage = instantPrefetchSegments.resolveStage( + effectivePageModule, + branchStage, + ); + } + if (siblingInterceptElement !== null && instantPrefetchSegments) { + siblingInterceptElement = instantPrefetchSegments.wrap( + siblingInterceptElement, + siblingInterceptPageStage, + ); + } if (isSiblingIntercept && siblingInterceptElement !== null) { const layoutIndexesByTreePosition = new Map(); for (const [index, layoutModule] of (opts?.interceptLayouts ?? []).entries()) { @@ -636,21 +711,34 @@ export async function buildPageElements< interceptLayoutSegments, effectiveParams, ); - siblingInterceptElement = createElement( + let interceptLayoutElement: React.ReactNode = createElement( LayoutComponent, - { params: makeThenableParams(interceptLayoutParams) }, + { params: makeComponentParams(LayoutComponent, interceptLayoutParams) }, siblingInterceptElement, ); + if (instantPrefetchSegments) { + interceptLayoutElement = instantPrefetchSegments.wrap( + interceptLayoutElement, + siblingInterceptLayoutStages[layoutIndex] ?? "static", + ); + } + siblingInterceptElement = interceptLayoutElement; } } } + const pageElement = isSiblingIntercept + ? siblingInterceptElement + : EffectivePageComponent + ? createPageElement(EffectivePageComponent, pageProps, pageRenderDependency) + : null; + const stagedPageElement = + !isSiblingIntercept && pageElement !== null && instantPrefetchSegments + ? instantPrefetchSegments.wrap(pageElement, instantPrefetchSegments.stages.page) + : pageElement; + return buildAppPageElements({ - element: isSiblingIntercept - ? siblingInterceptElement - : EffectivePageComponent - ? createPageElement(EffectivePageComponent, pageProps, pageRenderDependency) - : null, + element: stagedPageElement, createPageElement, // Fall back to vinext's built-in default global error module so that // uncaught client render errors are caught by the route-level @@ -685,6 +773,13 @@ export async function buildPageElements< searchParams: pageSearchParamsThenable, slotOverrides, renderMode, + instantPrefetchSegments: instantPrefetchSegments + ? { + layoutStages: instantPrefetchSegments.stages.layouts, + resolveStage: instantPrefetchSegments.resolveStage, + wrap: instantPrefetchSegments.wrap, + } + : undefined, trailingSlash: options.trailingSlash, }); } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 2ebbe09b7d..693fd9f30e 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -38,7 +38,11 @@ import { renderAppPageHtmlStreamWithRecovery, type AppPageSsrHandler, } from "./app-page-stream.js"; -import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + type AppRscRenderMode, +} from "./app-rsc-render-mode.js"; import { createArtifactCompatibilityEnvelope, createArtifactCompatibilityGraphVersion, @@ -190,6 +194,7 @@ type RenderAppPageLifecycleOptions = { runWithSuppressedHookWarning(probe: () => Promise): Promise; scriptNonce?: string; clientReuseManifest?: ClientReuseManifestParseResult; + retainedPrefetchLayoutIds?: readonly string[]; skipDisposition?: ClientReuseManifestSkipDisposition; mountedSlotsHeader?: string | null; renderedPathAndSearch?: string | null; @@ -553,6 +558,102 @@ function isSkipTransportEnabled( return skipDisposition?.enabled === true; } +function createRetainedPrefetchLayoutSkipDisposition(input: { + element: ReactNode | Readonly>; + isRscRequest: boolean; + retainedPrefetchLayoutIds: readonly string[] | undefined; +}): ClientReuseManifestSkipDisposition | undefined { + if ( + !input.isRscRequest || + !input.retainedPrefetchLayoutIds || + input.retainedPrefetchLayoutIds.length === 0 || + !isAppElementsRecord(input.element) + ) { + return undefined; + } + + const skippedEntryIds: string[] = []; + const seen = new Set(); + for (const layoutId of input.retainedPrefetchLayoutIds) { + if (seen.has(layoutId)) continue; + if (AppElementsWire.parseElementKey(layoutId)?.kind !== "layout") continue; + if (!Object.hasOwn(input.element, layoutId)) continue; + seen.add(layoutId); + skippedEntryIds.push(layoutId); + } + + if (skippedEntryIds.length === 0) return undefined; + return { + code: "SKIP_RETAINED_PREFETCH_LAYOUTS", + enabled: true, + mode: "skipRetainedPrefetchLayouts", + retainedOnlySkippedEntryIds: skippedEntryIds, + skippedEntryIds, + }; +} + +function mergeSkipDispositions( + first: ClientReuseManifestSkipDisposition | undefined, + second: ClientReuseManifestSkipDisposition | undefined, +): ClientReuseManifestSkipDisposition | undefined { + if (first?.enabled !== true) return second ?? first; + if (second?.enabled !== true) return first; + + const skippedEntryIds = [...first.skippedEntryIds]; + const seen = new Set(skippedEntryIds); + for (const id of second.skippedEntryIds) { + if (seen.has(id)) continue; + seen.add(id); + skippedEntryIds.push(id); + } + + const standardReuseIds = new Set(); + for (const disposition of [first, second]) { + if (disposition.code !== "SKIP_STATIC_LAYOUT_VERIFIED") continue; + for (const id of disposition.skippedEntryIds) standardReuseIds.add(id); + } + + const retainedOnlySkippedEntryIds: string[] = []; + const seenRetainedOnly = new Set(); + for (const disposition of [first, second]) { + if (disposition.code !== "SKIP_RETAINED_PREFETCH_LAYOUTS") continue; + for (const id of disposition.retainedOnlySkippedEntryIds) { + if (standardReuseIds.has(id) || seenRetainedOnly.has(id)) continue; + seenRetainedOnly.add(id); + retainedOnlySkippedEntryIds.push(id); + } + } + + if ( + first.code !== "SKIP_RETAINED_PREFETCH_LAYOUTS" && + second.code !== "SKIP_RETAINED_PREFETCH_LAYOUTS" + ) { + return { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds, + }; + } + return { + code: "SKIP_RETAINED_PREFETCH_LAYOUTS", + enabled: true, + mode: "skipRetainedPrefetchLayouts", + retainedOnlySkippedEntryIds, + skippedEntryIds, + }; +} + +function collectResponseLayoutIds(input: { + element: ReactNode | AppOutgoingElements; + layoutFlags: Readonly>; +}): readonly string[] { + const layoutIds = Object.keys(input.layoutFlags); + if (!isAppElementsRecord(input.element)) return layoutIds; + const element = input.element; + return layoutIds.filter((layoutId) => Object.hasOwn(element, layoutId)); +} + /** * Wraps an RSC response body to report invalid dynamic usage errors after the * stream is fully consumed. In dev mode, errors from cookies()/headers() inside @@ -700,7 +801,7 @@ export async function renderAppPageLifecycle( rootBoundaryId, routePattern: options.routePattern, }); - const skipDisposition = + const clientReuseSkipDisposition = options.skipDisposition ?? createRenderLifecycleSkipDisposition({ artifactCompatibility, @@ -711,6 +812,14 @@ export async function renderAppPageLifecycle( layoutFlags, layoutParamAccess: options.layoutParamAccess, }); + const skipDisposition = mergeSkipDispositions( + clientReuseSkipDisposition, + createRetainedPrefetchLayoutSkipDisposition({ + element: options.element, + isRscRequest: options.isRscRequest, + retainedPrefetchLayoutIds: options.retainedPrefetchLayoutIds, + }), + ); const shouldBypassRscCacheForSkipTransport = options.isRscRequest && isSkipTransportEnabled(skipDisposition); const dynamicStaleTimeSeconds = @@ -726,6 +835,10 @@ export async function renderAppPageLifecycle( ...(artifactCompatibility ? { artifactCompatibility } : {}), skipDisposition: options.isRscRequest ? skipDisposition : undefined, }); + const responseLayoutIds = collectResponseLayoutIds({ + element: outgoingElement, + layoutFlags, + }); const compileEnd = options.isProduction ? undefined : performance.now(); const baseOnError = options.createRscOnErrorHandler(options.cleanPathname, options.routePattern); @@ -737,7 +850,43 @@ export async function renderAppPageLifecycle( // standalone call would establish here is only effective if the caller has // an outer runWithRequestContext / runWithFetchDedupe scope keeping the ALS // store alive across that consumption. + let instantPrefetchShellWasAborted = false; let rscStream = await runWithFetchDedupe(async () => { + if ( + (options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL || + options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL) && + options.prerenderToReadableStream + ) { + const { + beginInstantPrefetchShellFinalRender, + createInstantPrefetchShellState, + getInstantPrefetchShellReactSignal, + runWithInstantPrefetchShellState, + wasInstantPrefetchShellAborted, + } = await import("vinext/shims/instant-prefetch-shell"); + const shellState = createInstantPrefetchShellState( + options.cleanPathname, + options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL + ? "static" + : "runtime", + ); + // Instant shells stay open until every Cache Component already + // started by this render settles. The shared fallback-shell tracker then + // aborts only after completed branches have flushed, preserving cold + // cache fills without allowing request-time content beyond the selected + // static/runtime stage into the payload. + const pendingResult = runWithInstantPrefetchShellState(shellState, () => + options.prerenderToReadableStream!(outgoingElement, { + onError: rscErrorTracker.onRenderError, + signal: getInstantPrefetchShellReactSignal(shellState), + }), + ); + beginInstantPrefetchShellFinalRender(shellState); + const result = await pendingResult; + instantPrefetchShellWasAborted = wasInstantPrefetchShellAborted(shellState); + return result.prelude; + } + if (options.pprFallbackShellSignal && options.prerenderToReadableStream) { const reactSignal = options.pprFallbackShellReactSignal ?? options.pprFallbackShellSignal; const pendingResult = options.prerenderToReadableStream(outgoingElement, { @@ -820,7 +969,9 @@ export async function renderAppPageLifecycle( // When skip transport is enabled, omit cacheState because the response is a // per-client payload, not a shared-cache MISS/HIT artifact. The absence also // keeps finalizeAppPageRscCacheResponse from overwriting no-store. - const rscResponsePolicy = shouldBypassRscCacheForSkipTransport + const shouldBypassRscCache = + shouldBypassRscCacheForSkipTransport || instantPrefetchShellWasAborted; + const rscResponsePolicy = shouldBypassRscCache ? { cacheControl: NO_STORE_CACHE_CONTROL } : resolveAppPageRscResponsePolicy({ dynamicUsedDuringBuild, @@ -834,6 +985,8 @@ export async function renderAppPageLifecycle( }); if (shouldBypassRscCacheForSkipTransport) { options.isrDebug?.("RSC cache write skipped (skip transport payload)", options.cleanPathname); + } else if (instantPrefetchShellWasAborted) { + options.isrDebug?.("RSC cache write skipped (partial instant shell)", options.cleanPathname); } const shouldEmitDynamicStaleTime = dynamicStaleTimeSeconds !== undefined && @@ -857,9 +1010,11 @@ export async function renderAppPageLifecycle( // the conservative pending expiry after decoding. dynamicStaleTimeSeconds: shouldEmitDynamicStaleTime ? dynamicStaleTimeSeconds : undefined, isEdgeRuntime: options.isEdgeRuntime, + layoutIds: responseLayoutIds, middlewareContext: options.middlewareContext, mountedSlotsHeader: options.mountedSlotsHeader, params: options.navigationParams, + partialShell: instantPrefetchShellWasAborted, policy: rscResponsePolicy, renderedPathAndSearch: options.renderedPathAndSearch, requestCacheLife: requestCacheLifeForPrerender, @@ -928,7 +1083,9 @@ export async function renderAppPageLifecycle( return finalizeAppPageRscCacheResponse(devRscResponse, { capturedRscDataPromise: - options.isProduction && shouldCaptureRscForCacheMetadata ? capturedRscDataRef.value : null, + options.isProduction && shouldCaptureRscForCacheMetadata && !instantPrefetchShellWasAborted + ? capturedRscDataRef.value + : null, cleanPathname: options.cleanPathname, consumeDynamicUsage: finalizeRenderDynamicUsage, consumeRenderObservationState: options.consumeRenderObservationState, diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index e28fcedddd..f91fc9589e 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -10,6 +10,8 @@ import { VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_RSC_LAYOUT_IDS_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_TIMING_HEADER, } from "./headers.js"; @@ -77,9 +79,11 @@ type BuildAppPageRscResponseOptions = { /** The render is being captured for a cache write but streams before its cacheLife resolves. */ staleTimePending?: boolean; isEdgeRuntime?: boolean; + layoutIds?: readonly string[]; middlewareContext: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; params?: Record; + partialShell?: boolean; policy: AppPageResponsePolicy; renderedPathAndSearch?: string | null; requestCacheLife?: AppPagePrerenderCacheLife | null; @@ -310,6 +314,9 @@ export function buildAppPageRscResponse( if (options.mountedSlotsHeader) { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } + if (options.layoutIds && options.layoutIds.length > 0) { + headers.set(VINEXT_RSC_LAYOUT_IDS_HEADER, options.layoutIds.join(" ")); + } applyDynamicStaleTimeHeader(headers, options.dynamicStaleTimeSeconds); if (options.staleTimePending) { headers.set(VINEXT_STALE_TIME_PENDING_HEADER, "1"); @@ -321,6 +328,11 @@ export function buildAppPageRscResponse( setCacheStateHeaders(headers, options.policy.cacheState); } mergeMiddlewareResponseHeaders(headers, options.middlewareContext.headers); + if (options.partialShell) { + headers.set(VINEXT_RSC_PARTIAL_SHELL_HEADER, "1"); + } else { + headers.delete(VINEXT_RSC_PARTIAL_SHELL_HEADER); + } if (options.renderedPathAndSearch) { headers.set( VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index cb53a5a19a..518c71cf73 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -36,9 +36,10 @@ import { StreamedIconsInsertion } from "vinext/shims/streamed-icons"; import { createInlineScriptTag, escapeHtmlAttr } from "./html.js"; import type { AppPageParams } from "./app-page-boundary.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; -import type { ThenableParamsObserver } from "vinext/shims/thenable-params"; +import type { ThenableParamsObserver, ThenableParamsOptions } from "vinext/shims/thenable-params"; import { createAppRenderDependency, + isClientReferenceAppComponent, registerAppElementRenderDependencies, renderAfterAppDependencies, renderAppComponentWithDependencyBarrier, @@ -55,6 +56,7 @@ import { APP_RSC_RENDER_MODE_NAVIGATION, APP_RSC_RENDER_MODE_PREFETCH_EMPTY, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, type AppRscRenderMode, } from "./app-rsc-render-mode.js"; import { @@ -246,9 +248,22 @@ type BuildAppPageRouteElementOptions< pageRenderDependency?: AppPageRenderDependency | null; searchParams?: unknown; slotOverrides?: Readonly>> | null; + /** Segment-local instant-prefetch staging for the main and parallel branches. */ + instantPrefetchSegments?: { + layoutStages: readonly ("runtime" | "static")[]; + resolveStage( + module: TModule | null | undefined, + inheritedStage: "runtime" | "static", + ): "runtime" | "static"; + wrap(element: ReactNode, stage: "runtime" | "static"): ReactNode; + }; }; -type MakeThenableParams = (params: AppPageParams, observer?: ThenableParamsObserver) => unknown; +type MakeThenableParams = ( + params: AppPageParams, + observer?: ThenableParamsObserver, + options?: ThenableParamsOptions, +) => unknown; type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, @@ -813,6 +828,17 @@ export function buildAppPageElements< const interceptionContext = renderIdentity?.interceptionContext ?? options.interceptionContext ?? null; const renderMode = options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION; + const makeComponentParams = ( + component: AppPageComponent, + params: AppPageParams, + observer?: ThenableParamsObserver, + ): unknown => + options.makeThenableParams(params, observer, { + suspendStaticInstantShell: !( + renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL && + isClientReferenceAppComponent(component) + ), + }); const routeSegments = options.route.routeSegments ?? []; const routeId = renderIdentity?.routeId ?? @@ -1156,7 +1182,8 @@ export function buildAppPageElements< }); const layoutProps: Record = { - params: options.makeThenableParams( + params: makeComponentParams( + layoutComponent, layoutParams, options.layoutParamAccess?.createThenableParamsObserver(layoutEntry.id), ), @@ -1184,6 +1211,12 @@ export function buildAppPageElements< ); + if (options.instantPrefetchSegments) { + layoutElement = options.instantPrefetchSegments.wrap( + layoutElement, + options.instantPrefetchSegments.layoutStages[index], + ); + } const ancestorLoadingEntry = findNearestAncestorLoadingEntry(layoutEntry.treePosition); const ancestorLoadingComponent = getDefaultExport(ancestorLoadingEntry?.loadingModule); if (ancestorLoadingComponent && ancestorLoadingEntry) { @@ -1239,9 +1272,14 @@ export function buildAppPageElements< if (isPrefetchLoadingShell && prefetchSlotLoadingEntry === null) { continue; } - const overrideOrPageComponent = - getDefaultExport(slotOverride?.pageModule) ?? getDefaultExport(slot.page); - const defaultComponent = getDefaultExport(slot.default); + const overrideOrPageModule = getDefaultExport(slotOverride?.pageModule) + ? slotOverride?.pageModule + : getDefaultExport(slot.page) + ? slot.page + : null; + const defaultModule = getDefaultExport(slot.default) ? slot.default : null; + const overrideOrPageComponent = getDefaultExport(overrideOrPageModule); + const defaultComponent = getDefaultExport(defaultModule); // On soft nav (RSC): omit key when only default.tsx exists and the slot is // already mounted on the client. Absent key means the browser retains prior @@ -1256,7 +1294,8 @@ export function buildAppPageElements< continue; } - const slotComponent = overrideOrPageComponent ?? defaultComponent; + const slotModule = overrideOrPageModule ?? defaultModule; + const slotComponent = getDefaultExport(slotModule); if (!slotComponent && !isOwnedAtRoutePrefetchCutoff) { elements[slotId] = AppElementsWire.unmatchedSlotValue; @@ -1270,7 +1309,7 @@ export function buildAppPageElements< )!; slotElement = ; } else { - const slotThenableParams = options.makeThenableParams(slotParams); + const slotThenableParams = makeComponentParams(slotComponent!, slotParams); const slotProps: Record = { params: slotThenableParams, }; @@ -1296,11 +1335,20 @@ export function buildAppPageElements< } const branchLayouts = new Map< number, - { component: AppPageComponent; params: AppPageParams }[] + { + component: AppPageComponent; + instantStage?: "runtime" | "static"; + module: TModule | null | undefined; + params: AppPageParams; + }[] >(); const addBranchLayout = ( treePosition: number, - entry: { component: AppPageComponent; params: AppPageParams }, + entry: { + component: AppPageComponent; + module: TModule | null | undefined; + params: AppPageParams; + }, ) => { const entries = branchLayouts.get(treePosition) ?? []; entries.push(entry); @@ -1315,6 +1363,7 @@ export function buildAppPageElements< slotOverride?.layoutSegments?.[layoutIndex]?.length ?? branchSegments.length; addBranchLayout(treePosition, { component, + module: layoutModule, params: resolveSlotLayoutParams(branchSegments, treePosition, slotParams), }); } @@ -1325,6 +1374,7 @@ export function buildAppPageElements< const treePosition = slot.configLayoutTreePositions?.[layoutIndex] ?? 0; addBranchLayout(treePosition, { component, + module: layoutModule, params: { ...slotOwnerParams, ...resolveSlotLayoutParams(slotRouteSegments, treePosition, slotParams), @@ -1337,11 +1387,27 @@ export function buildAppPageElements< if (slotLayoutComponent) { const rootEntries = branchLayouts.get(0) ?? []; branchLayouts.set(0, [ - { component: slotLayoutComponent, params: slotOwnerParams }, + { component: slotLayoutComponent, module: slot.layout!, params: slotOwnerParams }, ...rootEntries, ]); } + if (options.instantPrefetchSegments) { + let branchStage = options.instantPrefetchSegments.layoutStages[targetIndex] ?? "static"; + const layoutTreePositions = [...branchLayouts.keys()].sort((left, right) => left - right); + for (const treePosition of layoutTreePositions) { + for (const layoutEntry of branchLayouts.get(treePosition) ?? []) { + branchStage = options.instantPrefetchSegments.resolveStage( + layoutEntry.module, + branchStage, + ); + layoutEntry.instantStage = branchStage; + } + } + const pageStage = options.instantPrefetchSegments.resolveStage(slotModule, branchStage); + slotElement = options.instantPrefetchSegments.wrap(slotElement, pageStage); + } + const branchLoadings = new Map(); for (const entry of slotLoadingEntries) { const component = getDefaultExport(entry.loadingModule); @@ -1412,11 +1478,18 @@ export function buildAppPageElements< for (let layoutIndex = layoutEntriesAtPosition.length - 1; layoutIndex >= 0; layoutIndex--) { const layoutEntry = layoutEntriesAtPosition[layoutIndex]; const LayoutComponent = layoutEntry.component; - slotElement = ( - + let layoutElement: ReactNode = ( + {slotElement} ); + if (options.instantPrefetchSegments && layoutEntry.instantStage) { + layoutElement = options.instantPrefetchSegments.wrap( + layoutElement, + layoutEntry.instantStage, + ); + } + slotElement = layoutElement; } } diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index 5bddc2d0b5..657b9641a0 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -28,6 +28,10 @@ const REACT_FORWARD_REF = Symbol.for("react.forward_ref"); const REACT_LAZY = Symbol.for("react.lazy"); const REACT_MEMO = Symbol.for("react.memo"); +export function isClientReferenceAppComponent(component: unknown): boolean { + return (component as AppDependencyComponent | null)?.$$typeof === REACT_CLIENT_REFERENCE; +} + export function isReactOwnedAppComponent(component: unknown): boolean { const candidate = component as AppDependencyComponent | null; @@ -41,7 +45,7 @@ export function isReactOwnedAppComponent(component: unknown): boolean { return ( typeof candidate !== "function" || - candidate.$$typeof === REACT_CLIENT_REFERENCE || + isClientReferenceAppComponent(candidate) || candidate.prototype?.isReactComponent != null ); } diff --git a/packages/vinext/src/server/app-rsc-cache-busting.ts b/packages/vinext/src/server/app-rsc-cache-busting.ts index eff427f2a3..e4ad558761 100644 --- a/packages/vinext/src/server/app-rsc-cache-busting.ts +++ b/packages/vinext/src/server/app-rsc-cache-busting.ts @@ -14,6 +14,7 @@ import { VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, NEXTJS_DEPLOYMENT_ID_HEADER, + VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, } from "./headers.js"; import { applyDeploymentIdHeader, getDeploymentId } from "../utils/deployment-id.js"; @@ -39,6 +40,7 @@ export const VINEXT_RSC_VARY_HEADER = [ NEXT_URL_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, + VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, ].join(", "); @@ -49,6 +51,7 @@ type CreateRscRequestHeadersOptions = { clientReuseManifestHeader?: string | null; interceptionContext?: string | null; mountedSlotsHeader?: string | null; + retainedPrefetchLayoutsHeader?: string | null; includePrefetchHeader?: boolean; renderMode?: AppRscRenderMode; fetchPriority?: "auto" | "high" | "low"; @@ -174,6 +177,7 @@ function normalizeRenderModeHeaderValue(value: string | null): string | null { } type CreateCacheBustingInputOptions = { + includeRetainedPrefetchLayoutsHeader?: boolean; includeRenderModeHeader?: boolean; }; @@ -190,6 +194,9 @@ function createCacheBustingInput( headers.get(NEXT_URL_HEADER), headers.get(VINEXT_INTERCEPTION_CONTEXT_HEADER), headers.get(VINEXT_MOUNTED_SLOTS_HEADER), + ...(options.includeRetainedPrefetchLayoutsHeader === false + ? [] + : [headers.get(VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER)]), ...(options.includeRenderModeHeader === false ? [] : [normalizeRenderModeHeaderValue(headers.get(VINEXT_RSC_RENDER_MODE_HEADER))]), @@ -213,7 +220,10 @@ function computeLegacyRscCacheBustingSearchParam(headers: Headers): string { } async function computePreviousRscCacheBustingSearchParam(headers: Headers): Promise { - const input = createCacheBustingInput(headers, { includeRenderModeHeader: false }); + const input = createCacheBustingInput(headers, { + includeRenderModeHeader: false, + includeRetainedPrefetchLayoutsHeader: false, + }); if (input === null) { return null; } @@ -222,10 +232,22 @@ async function computePreviousRscCacheBustingSearchParam(headers: Headers): Prom } function computePreviousLegacyRscCacheBustingSearchParam(headers: Headers): string | null { - const input = createCacheBustingInput(headers, { includeRenderModeHeader: false }); + const input = createCacheBustingInput(headers, { + includeRenderModeHeader: false, + includeRetainedPrefetchLayoutsHeader: false, + }); return input === null ? null : fnv1a64(input); } +async function computePreviousRetainedLayoutlessRscCacheBustingSearchParam( + headers: Headers, +): Promise { + const input = createCacheBustingInput(headers, { + includeRetainedPrefetchLayoutsHeader: false, + }); + return input === null ? "" : sha256CacheBustingHash(input); +} + function getSearchPairsWithoutRscCacheBusting(url: URL): string[] { const rawQuery = url.search.startsWith("?") ? url.search.slice(1) : url.search; return rawQuery @@ -324,6 +346,13 @@ export function createRscRequestHeaders(options: CreateRscRequestHeadersOptions headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } + if ( + options.retainedPrefetchLayoutsHeader !== undefined && + options.retainedPrefetchLayoutsHeader !== null + ) { + headers.set(VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, options.retainedPrefetchLayoutsHeader); + } + if ( options.clientReuseManifestHeader !== undefined && options.clientReuseManifestHeader !== null @@ -398,6 +427,9 @@ export async function resolveInvalidRscCacheBustingRequest( const acceptedHashes = new Set([expectedHash]); if (actualHash !== null && actualHash !== expectedHash) { acceptedHashes.add(computeLegacyRscCacheBustingSearchParam(options.request.headers)); + acceptedHashes.add( + await computePreviousRetainedLayoutlessRscCacheBustingSearchParam(options.request.headers), + ); if ( normalizeRenderModeHeaderValue(options.request.headers.get(VINEXT_RSC_RENDER_MODE_HEADER)) === null diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index b8028b9bf7..520a0ca08c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -203,6 +203,7 @@ function applyMiddlewareContextToResponse( type DispatchMatchedPageOptions = { clientReuseManifest: ClientReuseManifestParseResult; + retainedPrefetchLayoutIds: readonly string[]; cleanPathname: string; displayPathname: string; formState: ReactFormState | null; @@ -593,6 +594,7 @@ async function handleAppRscRequest( mountedSlotsHeader, renderMode, clientReuseManifest, + retainedPrefetchLayoutIds, hadBasePath, } = normalized; const { requestCleanPathname } = normalized; @@ -1529,6 +1531,7 @@ async function handleAppRscRequest( const pageResponse = await options.dispatchMatchedPage({ clientReuseManifest, + retainedPrefetchLayoutIds, cleanPathname, displayPathname: canonicalPathname, formState, diff --git a/packages/vinext/src/server/app-rsc-render-mode.ts b/packages/vinext/src/server/app-rsc-render-mode.ts index a45e6b7e64..935b37d3ac 100644 --- a/packages/vinext/src/server/app-rsc-render-mode.ts +++ b/packages/vinext/src/server/app-rsc-render-mode.ts @@ -2,12 +2,18 @@ export type AppRscRenderMode = | "navigation" | "prefetch-empty" | "prefetch-dynamic-shell" + | "prefetch-instant-shell" + | "prefetch-static-instant-shell" | "prefetch-loading-shell"; export const APP_RSC_RENDER_MODE_NAVIGATION = "navigation" satisfies AppRscRenderMode; export const APP_RSC_RENDER_MODE_PREFETCH_EMPTY = "prefetch-empty" satisfies AppRscRenderMode; export const APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL = "prefetch-dynamic-shell" satisfies AppRscRenderMode; +export const APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL = + "prefetch-instant-shell" satisfies AppRscRenderMode; +export const APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL = + "prefetch-static-instant-shell" satisfies AppRscRenderMode; export const APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL = "prefetch-loading-shell" satisfies AppRscRenderMode; @@ -19,6 +25,12 @@ export function getRscRenderModeCacheVariant(mode: AppRscRenderMode): string | n if (mode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL) { return "prefetch-dynamic-shell"; } + if (mode === APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL) { + return "prefetch-instant-shell"; + } + if (mode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL) { + return "prefetch-static-instant-shell"; + } if (mode === APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL) { return "prefetch-loading-shell"; } @@ -32,6 +44,10 @@ export function parseAppRscRenderMode(value: string | null): AppRscRenderMode { return APP_RSC_RENDER_MODE_PREFETCH_EMPTY; case APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL: return APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL; + case APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL: + return APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL; + case APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL: + return APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL; case APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL: return APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL; case null: diff --git a/packages/vinext/src/server/app-rsc-request-normalization.ts b/packages/vinext/src/server/app-rsc-request-normalization.ts index b8d37dba8d..2e50985bf1 100644 --- a/packages/vinext/src/server/app-rsc-request-normalization.ts +++ b/packages/vinext/src/server/app-rsc-request-normalization.ts @@ -11,8 +11,10 @@ import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, + VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, } from "./headers.js"; +import { AppElementsWire } from "./app-elements.js"; import { parseClientReuseManifestHeader, type ClientReuseManifestParseResult, @@ -141,10 +143,30 @@ export type NormalizedRscRequest = { renderMode: AppRscRenderMode; /** Parsed ClientReuseManifest hint. Verification and skip authorization happen later. */ clientReuseManifest: ClientReuseManifestParseResult; + /** Layout IDs the client can restore from complete prefetch payloads. */ + retainedPrefetchLayoutIds: readonly string[]; /** Whether the incoming pathname included the configured basePath. */ hadBasePath: boolean; }; +const MAX_RETAINED_PREFETCH_LAYOUT_IDS = 64; +const MAX_RETAINED_PREFETCH_LAYOUT_ID_LENGTH = 512; + +export function parseRetainedPrefetchLayoutIds(rawHeader: string | null): readonly string[] { + if (!rawHeader) return []; + + const ids: string[] = []; + const seen = new Set(); + for (const token of rawHeader.trim().split(/\s+/)) { + if (!token || ids.length >= MAX_RETAINED_PREFETCH_LAYOUT_IDS) break; + if (token.length > MAX_RETAINED_PREFETCH_LAYOUT_ID_LENGTH || seen.has(token)) continue; + if (AppElementsWire.parseElementKey(token)?.kind !== "layout") continue; + seen.add(token); + ids.push(token); + } + return ids; +} + /** * Normalize an App Router RSC request. * @@ -275,9 +297,13 @@ export function normalizeRscRequest( const clientReuseManifest = isRscRequest ? parseClientReuseManifestHeader(request.headers.get(VINEXT_CLIENT_REUSE_MANIFEST_HEADER)) : ({ kind: "absent" } satisfies ClientReuseManifestParseResult); + const retainedPrefetchLayoutIds = isRscRequest + ? parseRetainedPrefetchLayoutIds(request.headers.get(VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER)) + : []; return { clientReuseManifest, + retainedPrefetchLayoutIds, hadBasePath, url, pathname, diff --git a/packages/vinext/src/server/app-rsc-response-finalizer.ts b/packages/vinext/src/server/app-rsc-response-finalizer.ts index c5749282b1..c5e5404c35 100644 --- a/packages/vinext/src/server/app-rsc-response-finalizer.ts +++ b/packages/vinext/src/server/app-rsc-response-finalizer.ts @@ -1,7 +1,7 @@ import type { NextHeader, NextI18nConfig } from "../config/next-config.js"; import type { RequestContext } from "../config/request-context.js"; -import { VINEXT_STATIC_FILE_HEADER } from "./headers.js"; -import { applyCdnResponseHeaders } from "./cache-control.js"; +import { VINEXT_RSC_PARTIAL_SHELL_HEADER, VINEXT_STATIC_FILE_HEADER } from "./headers.js"; +import { applyCdnResponseHeaders, NO_STORE_CACHE_CONTROL } from "./cache-control.js"; import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-cache-busting.js"; import { mergeVaryHeader } from "./middleware-response-headers.js"; import { hasBasePath, stripBasePath } from "../utils/base-path.js"; @@ -30,6 +30,19 @@ type FinalizeAppRscResponseOptions = { const HAS_CONFIG_HEADERS = process.env.__VINEXT_HAS_CONFIG_HEADERS !== "false"; const configHeadersAlreadyApplied = new WeakSet(); +function lockPartialShellCacheHeaders(response: Response, isPartialShell: boolean): void { + if (!isPartialShell) { + response.headers.delete(VINEXT_RSC_PARTIAL_SHELL_HEADER); + return; + } + + response.headers.set(VINEXT_RSC_PARTIAL_SHELL_HEADER, "1"); + applyCdnResponseHeaders(response.headers, { cacheControl: NO_STORE_CACHE_CONTROL }); + response.headers.delete("CDN-Cache-Control"); + response.headers.delete("Cloudflare-CDN-Cache-Control"); + response.headers.delete("Cache-Tag"); +} + /** Mark a response whose final target pipeline has already applied config headers. */ export function markAppRscResponseConfigHeadersApplied(response: Response): Response { configHeadersAlreadyApplied.add(response); @@ -83,6 +96,7 @@ export async function finalizeAppRscResponse( if (response.status >= 300 && response.status < 400) { return response; } + const isPartialShell = response.headers.get(VINEXT_RSC_PARTIAL_SHELL_HEADER) === "1"; if (!response.headers.has(VINEXT_STATIC_FILE_HEADER)) { const varyHeader = response.headers.get("Vary"); @@ -105,6 +119,7 @@ export async function finalizeAppRscResponse( } if (configHeadersAlreadyApplied.has(response)) { + lockPartialShellCacheHeaders(response, isPartialShell); return response; } await applyAppRscConfigHeaders(response.headers, request, options); @@ -116,5 +131,7 @@ export async function finalizeAppRscResponse( sanitizeMethodNotAllowedHeaders(response.headers, "GET, HEAD"); } + lockPartialShellCacheHeaders(response, isPartialShell); + return response; } diff --git a/packages/vinext/src/server/client-reuse-manifest.ts b/packages/vinext/src/server/client-reuse-manifest.ts index 67edf643f1..b5259189fd 100644 --- a/packages/vinext/src/server/client-reuse-manifest.ts +++ b/packages/vinext/src/server/client-reuse-manifest.ts @@ -154,6 +154,13 @@ export type ClientReuseManifestSkipDisposition = enabled: true; mode: "skipStaticLayout"; skippedEntryIds: readonly string[]; + }> + | Readonly<{ + code: "SKIP_RETAINED_PREFETCH_LAYOUTS"; + enabled: true; + mode: "skipRetainedPrefetchLayouts"; + retainedOnlySkippedEntryIds: readonly string[]; + skippedEntryIds: readonly string[]; }>; export type ClientReuseManifestParseResult = diff --git a/packages/vinext/src/server/headers.ts b/packages/vinext/src/server/headers.ts index 1bbdf56a69..50a8ccea3c 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -66,6 +66,12 @@ export const VINEXT_PARAMS_HEADER = "X-Vinext-Params"; /** Deduplicated, sorted list of mounted layout slots for cache keying. */ export const VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots"; +/** Client hint listing layout entries already held by complete prefetch payloads. */ +export const VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER = "X-Vinext-Retained-Prefetch-Layouts"; + +/** Layout entries physically present in this RSC response. */ +export const VINEXT_RSC_LAYOUT_IDS_HEADER = "X-Vinext-Rsc-Layout-Ids"; + /** Per-page dynamic stale time in seconds for App Router RSC responses. */ export const VINEXT_DYNAMIC_STALE_TIME_HEADER = "X-Vinext-Dynamic-Stale-Time"; @@ -87,6 +93,9 @@ export const VINEXT_INTERCEPTION_CONTEXT_HEADER = "X-Vinext-Interception-Context /** RSC render mode (e.g. "navigation", "prefetch"). */ export const VINEXT_RSC_RENDER_MODE_HEADER = "X-Vinext-Rsc-Render-Mode"; +/** Marks an RSC prefetch response that contains a partial Suspense shell. */ +export const VINEXT_RSC_PARTIAL_SHELL_HEADER = "X-Vinext-Rsc-Partial-Shell"; + /** Disabled-by-default client hint describing already-held App Router payload entries. */ export const VINEXT_CLIENT_REUSE_MANIFEST_HEADER = "X-Vinext-Client-Reuse-Manifest"; diff --git a/packages/vinext/src/server/prerender-work-unit-setup.ts b/packages/vinext/src/server/prerender-work-unit-setup.ts index b0147b16f5..fa27d77d67 100644 --- a/packages/vinext/src/server/prerender-work-unit-setup.ts +++ b/packages/vinext/src/server/prerender-work-unit-setup.ts @@ -1,14 +1,11 @@ /** * Sets up the work unit async storage for prerendering. * - * When VINEXT_PRERENDER=1, wraps execution in a workUnitAsyncStorage.run() - * with a PrerenderStore so that dynamic APIs (e.g., io()) can - * detect the prerender context and return hanging promises. + * Wraps every render in Next.js-compatible work-unit state. Build prerenders + * receive a PrerenderStore; request-time renders receive a RequestStore. * * Used by: app-rsc-entry.ts handler template. * - * TODO: If future dynamic APIs need request-scoped stores for normal (non-prerender) - * requests, add a `{ type: "request" }` store during normal request handling. */ import { workUnitAsyncStorage } from "vinext/shims/internal/work-unit-async-storage"; @@ -30,5 +27,6 @@ export function runWithPrerenderWorkUnit( ) .finally(() => controller.abort()); } - return fn(); + if (workUnitAsyncStorage.getStore() !== undefined) return fn(); + return workUnitAsyncStorage.run({ type: "request" }, fn); } diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 38b3034281..6909a7c42d 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -50,6 +50,10 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { isDraftModeEnabled, markDynamicUsage } from "./headers.js"; +import { + suspendStaticInstantPrefetchPrivateCache, + trackInstantPrefetchShellCacheTask, +} from "./instant-prefetch-shell.js"; import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; import { isMarkedAppPagePropsObject } from "./internal/app-page-props-cache-key.js"; import { getCurrentRootParams, type RootParams } from "./root-params.js"; @@ -540,6 +544,13 @@ export type RegisterCachedFunctionOptions = { decryptCaptures?: (value: unknown) => Promise; }; +function trackCacheTaskForShells(fn: () => Promise, cacheVariant: string): Promise { + return trackInstantPrefetchShellCacheTask( + () => trackPprFallbackShellCacheTask(fn, cacheVariant), + cacheVariant, + ); +} + /** * Register a function as a cached function. This is called by the Vite * transform for each "use cache" function. @@ -566,8 +577,17 @@ export function registerCachedFunction( // it's scoped to a single request and doesn't persist across HMR. const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development"; - const cachedFn = (...args: TArgs): Promise => - trackPprFallbackShellCacheTask(async (): Promise => { + const cachedFn = async (...args: TArgs): Promise => { + if (cacheVariant === "private") { + const parentCtx = cacheContextStorage.getStore(); + if (parentCtx && parentCtx.variant !== "private") { + throwPrivateUseCacheInsidePublicUseCacheError(); + } + const stagedPrivateCache = suspendStaticInstantPrefetchPrivateCache(); + if (stagedPrivateCache) return stagedPrivateCache; + } + + return trackCacheTaskForShells(async (): Promise => { const rsc = await getRscModule(); const keySeed = getUseCacheKeySeed(); const captures = options.decryptCaptures ? await options.decryptCaptures(args[0]) : undefined; @@ -588,30 +608,32 @@ export function registerCachedFunction( // from key). Falls back to stableStringify when RSC is unavailable. let cacheKey: string; try { - const processedArgs = - executionArgs.length > 0 - ? unwrapThenableObjectArray(executionArgs, { omitAppPageSearchParamsFromFirstArg }) - : []; - if (rsc && executionArgs.length > 0) { - // Temporary references let encodeReply handle non-serializable values - // (like React elements in args) by excluding them from the key. - const tempRefs = rsc.createClientTemporaryReferenceSet(); - // Unwrap Promise-augmented objects before encoding. - // Next.js 16 params/searchParams are created via - // Object.assign(Promise.resolve(obj), obj) — a Promise with own - // enumerable properties. encodeReply treats Promises as temporary - // references (excluded from the key), which means different param - // values (e.g., section:"sports" vs section:"electronics") produce - // identical cache keys. We must extract the plain data so the actual - // values are included in the cache key. - const encoded = await rsc.encodeReply(processedArgs, { - temporaryReferences: tempRefs, - }); - cacheKey = buildUseCacheKey(id, keySeed, await replyToCacheKey(encoded)); - } else { + const keyContext = cacheContextStorage.getStore() ?? createCacheContext(cacheVariant); + cacheKey = await cacheContextStorage.run(keyContext, async () => { + const processedArgs = + executionArgs.length > 0 + ? unwrapThenableObjectArray(executionArgs, { omitAppPageSearchParamsFromFirstArg }) + : []; + if (rsc && executionArgs.length > 0) { + // Temporary references let encodeReply handle non-serializable values + // (like React elements in args) by excluding them from the key. + const tempRefs = rsc.createClientTemporaryReferenceSet(); + // Unwrap Promise-augmented objects before encoding. + // Next.js 16 params/searchParams are created via + // Object.assign(Promise.resolve(obj), obj) — a Promise with own + // enumerable properties. encodeReply treats Promises as temporary + // references (excluded from the key), which means different param + // values (e.g., section:"sports" vs section:"electronics") produce + // identical cache keys. We must extract the plain data so the actual + // values are included in the cache key. + const encoded = await rsc.encodeReply(processedArgs, { + temporaryReferences: tempRefs, + }); + return buildUseCacheKey(id, keySeed, await replyToCacheKey(encoded)); + } const argsKey = processedArgs.length > 0 ? stableStringify(processedArgs) : undefined; - cacheKey = buildUseCacheKey(id, keySeed, argsKey); - } + return buildUseCacheKey(id, keySeed, argsKey); + }); } catch { // Non-serializable arguments — run without caching return fn(...callArgs); @@ -619,11 +641,6 @@ export function registerCachedFunction( // "use cache: private" uses per-request in-memory cache if (cacheVariant === "private") { - const parentCtx = cacheContextStorage.getStore(); - if (parentCtx && parentCtx.variant !== "private") { - throwPrivateUseCacheInsidePublicUseCacheError(); - } - if (typeof process !== "undefined" && process.env.VINEXT_PRERENDER === "1") { // Next.js treats "use cache: private" as dynamic during prerendering: // it is excluded from the static artifact and resolved per request. @@ -815,6 +832,7 @@ export function registerCachedFunction( return result; }, cacheVariant); + }; // Preserve the original function's arity on the wrapper. The wrapper is // declared as `(...args)` (arity 0), which hides the original signature. @@ -980,6 +998,19 @@ type CachedFunctionResult = { collectedResult: TCollected | undefined; }; +function createCacheContext(variant: string): CacheContext { + return { + tags: [], + lifeConfigs: [], + variant: variant || "default", + readRootParamNames: new Set(), + hasExplicitRevalidate: false, + hasExplicitExpire: false, + dynamicNestedCacheError: undefined, + invalidDynamicUsageError: undefined, + }; +} + async function runCachedFunctionWithContext< // oxlint-disable-next-line @typescript-eslint/no-explicit-any T extends (...args: any[]) => Promise, @@ -1019,16 +1050,7 @@ async function runCachedFunctionWithContext< } } - const ctx: CacheContext = { - tags: [], - lifeConfigs: [], - variant: variant || "default", - readRootParamNames: new Set(), - hasExplicitRevalidate: false, - hasExplicitExpire: false, - dynamicNestedCacheError: undefined, - invalidDynamicUsageError: undefined, - }; + const ctx = createCacheContext(variant); let collectedResult: TCollected | undefined; const result = await cacheContextStorage.run(ctx, async () => { diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 0716b44052..52fffd2d4c 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -34,6 +34,7 @@ import { getCdnCacheAdapter } from "./cdn-cache.js"; import { getDataCacheHandler, type CachedFetchValue } from "./cache-handler.js"; import { getRequestExecutionContext } from "./request-context.js"; import { addCollectedRequestTags, getCurrentFetchSoftTags } from "./fetch-cache.js"; +import { trackInstantPrefetchShellCacheTask } from "./instant-prefetch-shell.js"; import { ACTION_DID_REVALIDATE_DYNAMIC_ONLY, ACTION_DID_REVALIDATE_STATIC_AND_DYNAMIC, @@ -624,44 +625,46 @@ export function unstable_cache Promise>( tagHash: tags.length > 0 ? fnv1a64(JSON.stringify(tags)) : null, }); - const isDraftMode = isDraftModeEnabled(); - if (!isDraftMode) { - // Try to get from cache. Stale entries are usable in normal App Router - // requests, but foreground-refresh inside revalidation scopes so the - // regenerated page/route stores fresh data. - const softTags = getCurrentFetchSoftTags(); - const existing = _hasPendingRevalidatedTag([...tags, ...softTags]) - ? null - : await getDataCacheHandler().get(cacheKey, { - kind: "FETCH", - tags, - softTags, - }); - if (existing?.value && existing.value.kind === "FETCH") { - const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); - if (cached.ok) { - if (existing.cacheState === "stale") { - if (shouldServeStaleUnstableCacheEntry()) { - scheduleUnstableCacheBackgroundRevalidation(cacheKey, () => - refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), - ); + return trackInstantPrefetchShellCacheTask(async () => { + const isDraftMode = isDraftModeEnabled(); + if (!isDraftMode) { + // Try to get from cache. Stale entries are usable in normal App Router + // requests, but foreground-refresh inside revalidation scopes so the + // regenerated page/route stores fresh data. + const softTags = getCurrentFetchSoftTags(); + const existing = _hasPendingRevalidatedTag([...tags, ...softTags]) + ? null + : await getDataCacheHandler().get(cacheKey, { + kind: "FETCH", + tags, + softTags, + }); + if (existing?.value && existing.value.kind === "FETCH") { + const cached = tryDeserializeUnstableCacheResult(existing.value.data.body); + if (cached.ok) { + if (existing.cacheState === "stale") { + if (shouldServeStaleUnstableCacheEntry()) { + scheduleUnstableCacheBackgroundRevalidation(cacheKey, () => + refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds), + ); + return cached.value; + } + } else { return cached.value; } - } else { - return cached.value; } + // Corrupted entries fall through to a foreground refresh. } - // Corrupted entries fall through to a foreground refresh. } - } - // Cache miss — call the function inside the unstable_cache ALS scope - // so that headers()/cookies()/connection() can detect they're in a - // cache scope and throw an appropriate error. - if (isDraftMode) { - return await _unstableCacheAls.run(true, () => fn(...args)); - } - return await refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds); + // Cache miss — call the function inside the unstable_cache ALS scope + // so that headers()/cookies()/connection() can detect they're in a + // cache scope and throw an appropriate error. + if (isDraftMode) { + return await _unstableCacheAls.run(true, () => fn(...args)); + } + return await refreshUnstableCacheResult(fn, args, cacheKey, tags, revalidateSeconds); + }, "unstable-cache"); }; return cachedFn as T; diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 72e98b4d0c..da7a254b95 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -25,6 +25,7 @@ import { getOrCreateAls } from "./internal/als-registry.js"; import { markDynamicUsage } from "./headers.js"; import { _hasPendingRevalidatedTag, _setRequestScopedCacheLife } from "./cache-request-state.js"; import { getRequestExecutionContext } from "./request-context.js"; +import { trackInstantPrefetchShellCacheTask } from "./instant-prefetch-shell.js"; import { isInsideUnifiedScope, getRequestContext, @@ -1208,145 +1209,164 @@ function createPatchedFetch(): typeof globalThis.fetch { } } - const softTags = _getState().currentFetchSoftTags; - let fetchInit = stripNextFromInit(init, cacheDirective); - let cacheKey: string; - try { - cacheKey = await buildFetchCacheKey(input, fetchInit); - // Cache-key generation may consume and stash request bodies on fetchInit; - // normalize again so the real fetch receives the restored body. - fetchInit = stripNextFromInit(fetchInit, cacheDirective); - } catch (err) { - if ( - err instanceof BodyTooLargeForCacheKeyError || - err instanceof SkipCacheKeyGenerationError - ) { + return trackInstantPrefetchShellCacheTask(async () => { + const softTags = _getState().currentFetchSoftTags; + let fetchInit = stripNextFromInit(init, cacheDirective); + let cacheKey: string; + try { + cacheKey = await buildFetchCacheKey(input, fetchInit); + // Cache-key generation may consume and stash request bodies on fetchInit; + // normalize again so the real fetch receives the restored body. fetchInit = stripNextFromInit(fetchInit, cacheDirective); - // The developer opted into caching but we couldn't build a cache key - // (body too large / unserializable). That is an internal vinext - // limitation, not an explicit uncached-fetch decision, so record only - // the observation (downgrading the page output to fresh render) - // without marking the whole page dynamic. - recordDynamicFetchObservation(input); - return dedupeFetch(input, fetchInit); + } catch (err) { + if ( + err instanceof BodyTooLargeForCacheKeyError || + err instanceof SkipCacheKeyGenerationError + ) { + fetchInit = stripNextFromInit(fetchInit, cacheDirective); + // The developer opted into caching but we couldn't build a cache key + // (body too large / unserializable). That is an internal vinext + // limitation, not an explicit uncached-fetch decision, so record only + // the observation (downgrading the page output to fresh render) + // without marking the whole page dynamic. + recordDynamicFetchObservation(input); + return dedupeFetch(input, fetchInit); + } + throw err; } - throw err; - } - const handler = getDataCacheHandler(); - let mustBypassPendingRevalidation = _hasPendingRevalidatedTag([...tags, ...softTags]); + const handler = getDataCacheHandler(); + let mustBypassPendingRevalidation = _hasPendingRevalidatedTag([...tags, ...softTags]); - // Try cache first - try { - let cached = mustBypassPendingRevalidation - ? null - : await handler.get(cacheKey, { - kind: "FETCH", + // Try cache first + try { + let cached = mustBypassPendingRevalidation + ? null + : await handler.get(cacheKey, { + kind: "FETCH", + tags, + softTags, + revalidate: revalidateSeconds, + }); + if ( + cached?.value?.kind === "FETCH" && + _hasPendingRevalidatedTag([...(cached.value.tags ?? []), ...tags, ...softTags]) + ) { + mustBypassPendingRevalidation = true; + cached = null; + } + if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState !== "stale") { + await lowerFetchCacheRevalidateIfNeeded( + handler, + cacheKey, + cached.value, tags, - softTags, - revalidate: revalidateSeconds, - }); - if ( - cached?.value?.kind === "FETCH" && - _hasPendingRevalidatedTag([...(cached.value.tags ?? []), ...tags, ...softTags]) - ) { - mustBypassPendingRevalidation = true; - cached = null; - } - if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState !== "stale") { - await lowerFetchCacheRevalidateIfNeeded( - handler, - cacheKey, - cached.value, - tags, - revalidateSeconds, - ); - const cachedData = cached.value.data; - return buildCachedFetchResponse(cachedData, input); - } - - // Stale entry — we could do stale-while-revalidate here, but for fetch() - // the simpler approach is to just re-fetch (the page-level ISR handles SWR). - // However, if we have a stale entry, return it and trigger background refetch. - if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState === "stale") { - if (shouldRefreshStaleFetchInForeground()) { - const freshResponse = await dedupeFetch(input, fetchInit); - await writeFetchCacheResponse(handler, cacheKey, freshResponse, tags, revalidateSeconds); - return freshResponse; + revalidateSeconds, + ); + const cachedData = cached.value.data; + return buildCachedFetchResponse(cachedData, input); } - const staleData = cached.value.data; - - // Background refetch — deduped so only one in-flight refetch runs - // per cache key, preventing thundering herd on popular endpoints. - if (!pendingRefetches.has(cacheKey)) { - const refetchPromise = originalFetch(input, fetchInit) - .then(async (freshResp) => { - await writeFetchCacheResponse(handler, cacheKey, freshResp, tags, revalidateSeconds, { - cloneForReturn: false, + // Stale entry — we could do stale-while-revalidate here, but for fetch() + // the simpler approach is to just re-fetch (the page-level ISR handles SWR). + // However, if we have a stale entry, return it and trigger background refetch. + if (cached?.value && cached.value.kind === "FETCH" && cached.cacheState === "stale") { + if (shouldRefreshStaleFetchInForeground()) { + const freshResponse = await dedupeFetch(input, fetchInit); + await writeFetchCacheResponse( + handler, + cacheKey, + freshResponse, + tags, + revalidateSeconds, + ); + return freshResponse; + } + + const staleData = cached.value.data; + + // Background refetch — deduped so only one in-flight refetch runs + // per cache key, preventing thundering herd on popular endpoints. + if (!pendingRefetches.has(cacheKey)) { + const refetchPromise = originalFetch(input, fetchInit) + .then(async (freshResp) => { + await writeFetchCacheResponse( + handler, + cacheKey, + freshResp, + tags, + revalidateSeconds, + { + cloneForReturn: false, + }, + ); + }) + .catch((err) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + console.error( + `[vinext] fetch cache background revalidation failed for ${url} (key=${cacheKey.slice(0, 12)}...):`, + err, + ); + }) + .finally(() => { + // Only clear if we still own the slot — the timeout may have + // already replaced it with a newer refetch promise. + if (pendingRefetches.get(cacheKey) === refetchPromise) { + pendingRefetches.delete(cacheKey); + } + clearTimeout(timeoutId); }); - }) - .catch((err) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - console.error( - `[vinext] fetch cache background revalidation failed for ${url} (key=${cacheKey.slice(0, 12)}...):`, - err, - ); - }) - .finally(() => { - // Only clear if we still own the slot — the timeout may have - // already replaced it with a newer refetch promise. + + pendingRefetches.set(cacheKey, refetchPromise); + + // Safety net: if the upstream fetch hangs forever, force-clean the + // dedup entry so future stale hits can retry instead of being + // permanently suppressed. + const timeoutId = setTimeout(() => { if (pendingRefetches.get(cacheKey) === refetchPromise) { pendingRefetches.delete(cacheKey); } - clearTimeout(timeoutId); - }); + }, DEDUP_TIMEOUT_MS); - pendingRefetches.set(cacheKey, refetchPromise); + getRequestExecutionContext()?.waitUntil(refetchPromise); + } - // Safety net: if the upstream fetch hangs forever, force-clean the - // dedup entry so future stale hits can retry instead of being - // permanently suppressed. - const timeoutId = setTimeout(() => { - if (pendingRefetches.get(cacheKey) === refetchPromise) { - pendingRefetches.delete(cacheKey); - } - }, DEDUP_TIMEOUT_MS); - - getRequestExecutionContext()?.waitUntil(refetchPromise); + // Return stale data immediately + return buildCachedFetchResponse(staleData, input); } - - // Return stale data immediately - return buildCachedFetchResponse(staleData, input); + } catch (cacheErr) { + // Cache read failed — fall through to network + console.error("[vinext] fetch cache read error:", cacheErr); } - } catch (cacheErr) { - // Cache read failed — fall through to network - console.error("[vinext] fetch cache read error:", cacheErr); - } - // Cache miss — fetch from network - const response = await (mustBypassPendingRevalidation - ? originalFetch(input, fetchInit) - : dedupeFetch(input, fetchInit)); - - const cacheValue = await buildFetchCacheValue(response, tags, revalidateSeconds); - if (cacheValue) { - handler - .set(cacheKey, cacheValue, { - fetchCache: true, - tags, - revalidate: revalidateSeconds, - }) - .catch((err) => { - console.error("[vinext] fetch cache write error:", err); - }); - } + // Cache miss — fetch from network + const response = await (mustBypassPendingRevalidation + ? originalFetch(input, fetchInit) + : dedupeFetch(input, fetchInit)); + + const cacheValue = await buildFetchCacheValue(response, tags, revalidateSeconds); + if (cacheValue) { + const cacheWrite = handler + .set(cacheKey, cacheValue, { + fetchCache: true, + tags, + revalidate: revalidateSeconds, + }) + .catch((err) => { + console.error("[vinext] fetch cache write error:", err); + }); + // Dynamic responses keep the existing fire-and-forget write behavior, + // while a prospective instant-shell render must keep its cache signal + // open until the cold entry is durable. + void trackInstantPrefetchShellCacheTask(() => cacheWrite, "fetch"); + } - return response; + return response; + }, "fetch"); } as typeof globalThis.fetch; } diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 67c99110a3..c53409e502 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -24,6 +24,7 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { createPprFallbackShellSuspensePromise } from "./ppr-fallback-shell.js"; +import { suspendStaticInstantPrefetchRequestData } from "./instant-prefetch-shell.js"; import type { RenderRequestApiKind } from "../server/cache-proof.js"; import type { ReadonlyRequestCookies } from "@vinext/types/next/upstream/dist/server/web/spec-extension/adapters/request-cookies"; import type { ResponseCookie } from "@vinext/types/next/upstream/dist/compiled/@edge-runtime/cookies/index"; @@ -985,6 +986,10 @@ export function headers(): Promise & Headers { } markDynamicUsage(); + const instantShellPromise = suspendStaticInstantPrefetchRequestData("headers()"); + if (instantShellPromise) { + return _decorateSuspendingRequestApiPromise(instantShellPromise); + } const fallbackShellPromise = createPprFallbackShellSuspensePromise("`headers()`"); if (fallbackShellPromise) { return _decorateSuspendingRequestApiPromise(fallbackShellPromise); @@ -1020,6 +1025,10 @@ function cookiesImpl(): Promise & RequestCookies { } markDynamicUsage(); + const instantShellPromise = suspendStaticInstantPrefetchRequestData("cookies()"); + if (instantShellPromise) { + return _decorateSuspendingRequestApiPromise(instantShellPromise); + } const fallbackShellPromise = createPprFallbackShellSuspensePromise("`cookies()`"); if (fallbackShellPromise) { return _decorateSuspendingRequestApiPromise(fallbackShellPromise); @@ -1184,6 +1193,10 @@ export async function draftMode(): Promise { if (!context) { throw createDraftModeScopeError("draftMode()"); } + const instantShellPromise = suspendStaticInstantPrefetchRequestData("draftMode()"); + if (instantShellPromise) { + await instantShellPromise; + } // Reading `draftMode()` itself is not dynamic — `isEnabled` is a plain // getter and merely calling `draftMode()` does not require bailing out // of static prerendering. Only `enable()`/`disable()` mutate state and diff --git a/packages/vinext/src/shims/instant-prefetch-shell.ts b/packages/vinext/src/shims/instant-prefetch-shell.ts new file mode 100644 index 0000000000..b0b85d88e1 --- /dev/null +++ b/packages/vinext/src/shims/instant-prefetch-shell.ts @@ -0,0 +1,203 @@ +import { getOrCreateAls } from "./internal/als-registry.js"; +import { makeHangingPromise } from "./internal/make-hanging-promise.js"; + +export type InstantPrefetchShellState = { + dynamicAbortController: AbortController; + hasDynamicBoundary: boolean; + isFinalRenderStarted: boolean; + pendingAbortCleanup: (() => void) | null; + pendingCacheTasks: number; + reactAbortController: AbortController; + route: string; + stage: "runtime" | "static"; +}; + +const instantPrefetchShellAls = getOrCreateAls( + "vinext.instantPrefetchShell.als", +); +const instantPrefetchShellStageAls = getOrCreateAls<"runtime" | "static">( + "vinext.instantPrefetchShell.stageAls", +); + +const useCacheAlsKey = Symbol.for("vinext.cacheRuntime.contextAls"); +const unstableCacheAlsKey = Symbol.for("vinext.unstableCache.als"); + +type CacheScopeStorage = { + getStore: () => unknown; +}; + +function getCacheScopeStore(key: symbol): unknown { + const storage = Reflect.get(globalThis, key); + if (!storage || typeof storage !== "object") return undefined; + const getStore = Reflect.get(storage, "getStore"); + if (typeof getStore !== "function") return undefined; + return getStore.call(storage as CacheScopeStorage); +} + +function isInsideStaticRequestDataCacheScope(): boolean { + const useCacheStore = getCacheScopeStore(useCacheAlsKey); + if ( + useCacheStore && + typeof useCacheStore === "object" && + Reflect.get(useCacheStore, "variant") !== "private" + ) { + return true; + } + return getCacheScopeStore(unstableCacheAlsKey) === true; +} + +function scheduleAfterTask(callback: () => void): () => void { + let firstTimer: ReturnType | null = setTimeout(() => { + firstTimer = null; + secondTimer = setTimeout(() => { + secondTimer = null; + callback(); + }, 0); + }, 0); + let secondTimer: ReturnType | null = null; + + return () => { + if (firstTimer !== null) clearTimeout(firstTimer); + if (secondTimer !== null) clearTimeout(secondTimer); + }; +} + +function cancelPendingAbort(state: InstantPrefetchShellState): void { + if (state.pendingAbortCleanup === null) return; + state.pendingAbortCleanup(); + state.pendingAbortCleanup = null; +} + +function scheduleAbortIfReady(state: InstantPrefetchShellState): void { + if ( + !state.isFinalRenderStarted || + !state.hasDynamicBoundary || + state.pendingCacheTasks > 0 || + state.pendingAbortCleanup !== null || + state.reactAbortController.signal.aborted + ) { + return; + } + + state.pendingAbortCleanup = scheduleAfterTask(() => { + state.pendingAbortCleanup = null; + if ( + state.isFinalRenderStarted && + state.hasDynamicBoundary && + state.pendingCacheTasks === 0 && + !state.reactAbortController.signal.aborted + ) { + // Keep the dynamic promise pending so React records a hole instead of + // serializing its abort rejection into the Flight payload. + state.reactAbortController.abort(); + } + }); +} + +export function createInstantPrefetchShellState( + route: string, + stage: "runtime" | "static" = "runtime", +): InstantPrefetchShellState { + return { + dynamicAbortController: new AbortController(), + hasDynamicBoundary: false, + isFinalRenderStarted: false, + pendingAbortCleanup: null, + pendingCacheTasks: 0, + reactAbortController: new AbortController(), + route, + stage, + }; +} + +export function runWithInstantPrefetchShellState( + state: InstantPrefetchShellState, + fn: () => T, +): T { + return instantPrefetchShellAls.run(state, fn); +} + +/** Scope request-data suspension to the segment currently rendered by React. */ +export function runWithInstantPrefetchShellStage(stage: "runtime" | "static", fn: () => T): T { + return instantPrefetchShellStageAls.run(stage, fn); +} + +export function trackInstantPrefetchShellCacheTask( + fn: () => Promise, + _cacheVariant: string, +): Promise { + const state = instantPrefetchShellAls.getStore(); + if (state === undefined) return fn(); + + cancelPendingAbort(state); + state.pendingCacheTasks++; + let promise: Promise; + try { + promise = fn(); + } catch (error) { + state.pendingCacheTasks--; + scheduleAbortIfReady(state); + return Promise.reject(error); + } + return promise.finally(() => { + state.pendingCacheTasks--; + scheduleAbortIfReady(state); + }); +} + +export function beginInstantPrefetchShellFinalRender(state: InstantPrefetchShellState): void { + state.isFinalRenderStarted = true; + scheduleAbortIfReady(state); +} + +export function getInstantPrefetchShellReactSignal(state: InstantPrefetchShellState): AbortSignal { + return state.reactAbortController.signal; +} + +export function wasInstantPrefetchShellAborted(state: InstantPrefetchShellState): boolean { + return state.hasDynamicBoundary && state.reactAbortController.signal.aborted; +} + +export function suspendInstantPrefetchConnection(): Promise | null { + const state = instantPrefetchShellAls.getStore(); + if (!state) return null; + state.hasDynamicBoundary = true; + scheduleAbortIfReady(state); + return makeHangingPromise(state.dynamicAbortController.signal, state.route, "connection()"); +} + +/** + * Static instant shells stop before request-time data. Runtime instant shells + * intentionally include headers/cookies and stop only at `connection()`. + */ +export function suspendStaticInstantPrefetchRequestData(expression: string): Promise | null { + const state = instantPrefetchShellAls.getStore(); + const stage = instantPrefetchShellStageAls.getStore() ?? state?.stage; + if (!state || stage !== "static") return null; + // Public cache scopes own the request data they admitted into their key. + // Suspending those reads would leave the tracked cache task waiting for the + // static-stage abort while the abort itself waits for that task to settle. + // Next.js similarly exposes cache-scope providers instead of the staged + // request-data promise from inside `cache` / `unstable-cache` work units. + if (isInsideStaticRequestDataCacheScope()) return null; + state.hasDynamicBoundary = true; + scheduleAbortIfReady(state); + return makeHangingPromise(state.dynamicAbortController.signal, state.route, expression); +} + +/** + * A private cache is request-stage work. Stop before both lookup and execution + * so even a warm per-request hit becomes a hole in a static instant shell. + */ +export function suspendStaticInstantPrefetchPrivateCache(): Promise | null { + const state = instantPrefetchShellAls.getStore(); + const stage = instantPrefetchShellStageAls.getStore() ?? state?.stage; + if (!state || stage !== "static") return null; + state.hasDynamicBoundary = true; + scheduleAbortIfReady(state); + return makeHangingPromise( + state.dynamicAbortController.signal, + state.route, + '"use cache: private"', + ); +} diff --git a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts index 84e98b67dc..b9d3340e39 100644 --- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -44,6 +44,8 @@ export type AppRoutePrefetchPolicy = { * between `auto` and `full` in `getPrefetchEntryCacheStatus`. */ honorDynamicStaleTime: boolean; + /** Render the configured `unstable_instant` shell stage. */ + prefetchInstantShell?: "runtime" | "static"; prefetchShellFirst: boolean; /** Fetch the route tree before the concrete page segment. */ requiresRouteTreePrefetch?: true; @@ -74,23 +76,54 @@ const NO_APP_ROUTE_PREFETCH: AppRoutePrefetchPolicy = { shouldPrefetch: false, }; +function resolveMatchedAppRoute(href: string): VinextLinkPrefetchRoute | null { + if (typeof window === "undefined") return null; + const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__; + if (!routes) return null; + const routeHref = toSameOriginRouteHref(href); + if (routeHref === null) return null; + return matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache)?.route ?? null; +} + +function runtimeInstantPolicy(): AppRoutePrefetchPolicy { + return { + // The response is a partial Suspense shell. It teaches optimistic routing + // what can commit immediately, while the click still issues the complete + // navigation request for blocked dynamic branches. + cacheForNavigation: false, + fallbackTtl: "dynamic", + honorDynamicStaleTime: true, + prefetchInstantShell: "runtime", + prefetchShellFirst: false, + shouldPrefetch: true, + }; +} + +function staticInstantPolicy(): AppRoutePrefetchPolicy { + return { + // Next reuses independently cached static segments. Until Vinext has that + // per-segment cache, render the same cache-aware instant shell but keep it + // learning-only so dynamic branches still require the click-time request. + cacheForNavigation: false, + fallbackTtl: "static", + honorDynamicStaleTime: true, + prefetchInstantShell: "static", + prefetchShellFirst: false, + shouldPrefetch: true, + }; +} + export function canAutoPrefetchFullAppRoute(href: string): boolean { return resolveAutoAppRoutePrefetch(href).cacheForNavigation; } export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolicy { - if (typeof window === "undefined") return NO_APP_ROUTE_PREFETCH; - - const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__; - if (!routes) return NO_APP_ROUTE_PREFETCH; - const routeHref = toSameOriginRouteHref(href); if (routeHref === null) return NO_APP_ROUTE_PREFETCH; - - const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache); - if (!match) return NO_APP_ROUTE_PREFETCH; - - const route = match.route; + const route = resolveMatchedAppRoute(href); + if (!route) return NO_APP_ROUTE_PREFETCH; + if (route.hasRuntimeInstant) return runtimeInstantPolicy(); + if (route.hasInstant) return staticInstantPolicy(); const requiresRouteTreePrefetch = String(process.env.__NEXT_CACHE_COMPONENTS) === "true" && route.hasRootParams === true; // A search-param href renders query-specific output, so its payload can only @@ -133,7 +166,12 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic }; } -export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy { +export function resolveFullAppRoutePrefetch(href: string): AppRoutePrefetchPolicy { + const route = resolveMatchedAppRoute(href); + if (route?.hasRuntimeInstant) return runtimeInstantPolicy(); + // Next ignores the explicit "full" strategy for any truthy + // `unstable_instant` config. + if (route?.hasInstant) return resolveAutoAppRoutePrefetch(href); return { cacheForNavigation: true, fallbackTtl: "static", diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index b915652310..3dc53fb1b1 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -422,7 +422,9 @@ function prefetchUrl( rscCacheBusting, { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, }, headersModule, hybridRouteOwner, @@ -442,6 +444,7 @@ function prefetchUrl( getPrefetchInterceptionContext, getPrefetchCache, getPrefetchedUrls, + getRetainedPrefetchLayoutClaims, getMountedSlotsHeader, createAppPrefetchRequestHeaders, discardLearningOnlyPrefetchCacheEntry, @@ -480,12 +483,23 @@ function prefetchUrl( const autoPrefetch = mode === "auto" ? resolveAutoAppRoutePrefetch(prefetchPolicyHref) - : resolveFullAppRoutePrefetch(); + : resolveFullAppRoutePrefetch(prefetchPolicyHref); if (!autoPrefetch.shouldPrefetch) return; const interceptionContext = getPrefetchInterceptionContext(fullHref); const mountedSlotsHeader = getMountedSlotsHeader(); - const isOptimisticRouteShellPrefetch = !autoPrefetch.cacheForNavigation; + const retainedLayoutClaims = getRetainedPrefetchLayoutClaims({ + interceptionContext, + mountedSlotsHeader, + targetHref: fullHref, + }); + const retainedPrefetchLayoutsHeader = + retainedLayoutClaims.length === 0 + ? null + : retainedLayoutClaims.map((claim) => claim.layoutId).join(" "); + const isInstantShellPrefetch = autoPrefetch.prefetchInstantShell; + const isOptimisticRouteShellPrefetch = + !autoPrefetch.cacheForNavigation && !isInstantShellPrefetch; const hasSearchParams = new URL(fullHref, window.location.href).search !== ""; const isAutomaticSearchParamShell = mode === "auto" && isOptimisticRouteShellPrefetch && hasSearchParams; @@ -500,13 +514,18 @@ function prefetchUrl( interceptionContext, fetchPriority: priority, prefetchKind: mode === "full" ? "full" : "auto", - renderMode: isOptimisticRouteShellPrefetch - ? hasSearchAgnosticShell - ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL - : isAutomaticSearchParamShell - ? APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL - : APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL - : undefined, + retainedPrefetchLayoutsHeader, + renderMode: isInstantShellPrefetch + ? autoPrefetch.prefetchInstantShell === "static" + ? APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL + : APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL + : isOptimisticRouteShellPrefetch + ? hasSearchAgnosticShell + ? APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL + : isAutomaticSearchParamShell + ? APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL + : APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL + : undefined, }); if (mountedSlotsHeader) { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); @@ -542,6 +561,19 @@ function prefetchUrl( ) { return; } + // A learning-only policy does not make an independently completed + // navigation response weaker. If a fresh reusable snapshot already + // covers this href, avoid racing a redundant partial prefetch against + // the click that will consume it. This also covers complete runtime + // instant shells promoted under a normalized `_rsc` variant. + if ( + !autoPrefetch.cacheForNavigation && + hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader, { + additionalRscUrls, + }) + ) { + return; + } const fetchFullRscPayload = () => scheduleAppPrefetchFetch( (signal) => @@ -559,6 +591,7 @@ function prefetchUrl( const shellHeaders = createAppPrefetchRequestHeaders({ interceptionContext, fetchPriority: priority, + retainedPrefetchLayoutsHeader, renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, }); shellHeaders.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); @@ -593,6 +626,7 @@ function prefetchUrl( cacheForNavigation: false, optimisticRouteShell: true, prefetchKind: "loading-shell", + retainedLayoutClaims, }, ); shellEntry = shellCache.get(shellCacheKey); @@ -603,6 +637,7 @@ function prefetchUrl( const probeHeaders = createAppPrefetchRequestHeaders({ interceptionContext, fetchPriority: priority, + retainedPrefetchLayoutsHeader, renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, }); probeHeaders.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); @@ -691,6 +726,7 @@ function prefetchUrl( headers, interceptionContext, mountedSlotsHeader, + retainedLayoutClaims, }); })() : fetchFullRscPayload(); @@ -717,11 +753,20 @@ function prefetchUrl( ? DYNAMIC_NAVIGATION_CACHE_TTL : PREFETCH_CACHE_TTL, honorDynamicStaleTime: autoPrefetch.honorDynamicStaleTime, + instantShell: isInstantShellPrefetch !== undefined, + promoteCompleteInstantShell: isInstantShellPrefetch === "runtime", optimisticRouteShell: isOptimisticRouteShellPrefetch, - prefetchKind: isOptimisticRouteShellPrefetch ? "loading-shell" : "navigation", + prefetchKind: isInstantShellPrefetch + ? "instant-shell" + : isOptimisticRouteShellPrefetch + ? "loading-shell" + : "navigation", prepareSnapshot: autoPrefetch.cacheForNavigation ? prepareNavigationPrefetchSnapshot : undefined, + retainedLayoutClaims, + retainedLayoutProvider: + (mode === "full" || mode === "full-after-shell") && autoPrefetch.cacheForNavigation, searchAgnosticShell: isAutomaticSearchParamShell && !hasSearchAgnosticShell, }, ); @@ -793,7 +838,13 @@ async function promotePrefetchEntriesForNavigation(href: string): Promise } for (const [cacheKey, entry] of getPrefetchCache()) { - if (entry.optimisticRouteShell === true) continue; + if ( + entry.instantShell === true || + entry.optimisticRouteShell === true || + entry.partialSuspenseShell === true + ) { + continue; + } if (entry.prefetchKind === "route-tree") continue; const [rscUrl] = cacheKey.split("\0", 1); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 23a60daabe..e186ef810b 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -24,6 +24,8 @@ import { } from "../client/app-nav-failure-handler.js"; import { INITIAL_BFCACHE_ID, PUBLIC_INITIAL_BFCACHE_ID } from "../server/app-bfcache-id.js"; import { AppElementsWire, type AppElements } from "../server/app-elements.js"; +import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js"; +import { createRouteTrieCache, matchRouteWithTrie } from "../routing/route-matching.js"; import { resolveManifestNavigationInterceptionContext } from "../server/app-browser-interception-context.js"; import { createExternalHistoryStatePreservingMetadata, @@ -47,6 +49,9 @@ import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_RSC_COMPLETION_METADATA_HEADER, + VINEXT_RSC_LAYOUT_IDS_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, + VINEXT_RSC_RENDER_MODE_HEADER, VINEXT_STALE_TIME_PENDING_HEADER, } from "../server/headers.js"; import { extractRscCompletionMetadata } from "../server/rsc-completion-metadata.js"; @@ -63,7 +68,11 @@ import { isExternalUrl } from "../utils/external-url.js"; import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.js"; -import type { AppRscRenderMode } from "../server/app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + type AppRscRenderMode, +} from "../server/app-rsc-render-mode.js"; import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js"; import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js"; import { @@ -310,9 +319,13 @@ export type CachedRscResponse = { contentType: string; dynamicStaleTimeSeconds?: number; expiresAt?: number; + layoutIds?: readonly string[]; mountedSlotsHeader?: string | null; paramsHeader: string | null; preparedElements?: AppElements; + /** Exact in-memory providers authorized to fill layouts omitted from this payload. */ + retainedLayoutClaims?: readonly RetainedPrefetchLayoutClaim[]; + retainedLayoutProvider?: boolean; renderedPathAndSearch: string | null; serverStaleTime?: ServerStaleTime; url: string; @@ -323,7 +336,7 @@ export type PrefetchOptions = { onInvalidate?: () => void; }; -export type PrefetchCacheKind = "loading-shell" | "navigation" | "route-tree"; +export type PrefetchCacheKind = "instant-shell" | "loading-shell" | "navigation" | "route-tree"; export type PrefetchCacheEntry = { cacheForNavigation?: boolean; @@ -331,7 +344,9 @@ export type PrefetchCacheEntry = { invalidationTimer?: ReturnType; mountedSlotsHeader?: string | null; onInvalidateCallbacks?: Set<() => void>; + instantShell?: boolean; optimisticRouteShell?: boolean; + partialSuspenseShell?: boolean; outcome: "pending" | "cache-seeded"; snapshot?: CachedRscResponse; cacheKeys?: Set; @@ -340,11 +355,26 @@ export type PrefetchCacheEntry = { pending?: Promise; preparedElements?: AppElements; prefetchKind?: PrefetchCacheKind; + /** Complete full/runtime-prefetch payloads whose physical layouts may back later payloads. */ + retainedLayoutProvider?: boolean; + /** Provider entries that own layouts omitted from this payload. */ + retainedLayoutDependencies?: readonly PrefetchCacheEntry[]; + /** Layout-to-provider mapping used to reconstruct omitted layouts. */ + retainedLayoutClaims?: readonly RetainedPrefetchLayoutClaim[]; searchAgnosticShell?: boolean; size?: number; timestamp: number; }; +type RouteParams = Record; + +export type RetainedPrefetchLayoutClaim = Readonly<{ + layoutId: string; + provider: PrefetchCacheEntry; +}>; + +const retainedLayoutRouteTrieCache = createRouteTrieCache(); + export function getCurrentInterceptionContext(): string | null { if (isServer) { return null; @@ -386,6 +416,7 @@ export function createAppPrefetchRequestHeaders(options: { interceptionContext?: string | null; mountedSlotsHeader?: string | null; prefetchKind?: "auto" | "full"; + retainedPrefetchLayoutsHeader?: string | null; renderMode?: AppRscRenderMode; }): Headers { const prefetchRouterState = getNavigationRuntime()?.functions.getPrefetchRouterState?.() ?? null; @@ -433,6 +464,97 @@ export function getPrefetchedUrls(): Set { return window.__VINEXT_RSC_PREFETCHED_URLS__; } +function isRouteParams(value: unknown): value is RouteParams { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + for (const entry of Object.values(value)) { + if (typeof entry === "string") continue; + if (Array.isArray(entry) && entry.every((item) => typeof item === "string")) continue; + return false; + } + return true; +} + +function parseParamsHeader(value: string | null): RouteParams | null { + if (!value) return null; + try { + const parsed = JSON.parse(decodeURIComponent(value)); + return isRouteParams(parsed) ? parsed : null; + } catch { + return null; + } +} + +function areParamValuesEqual( + left: string | string[] | undefined, + right: string | string[] | undefined, +): boolean { + if (left === undefined || right === undefined) return false; + if (typeof left === "string" || typeof right === "string") return left === right; + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function getLayoutParamNames(layoutId: string): readonly string[] | null { + const parsed = AppElementsWire.parseElementKey(layoutId); + if (parsed?.kind !== "layout") return null; + + const names: string[] = []; + for (const segment of parsed.treePath.split("/")) { + if (segment.startsWith("[[...") && segment.endsWith("]]")) { + names.push(segment.slice(5, -2)); + } else if (segment.startsWith("[...") && segment.endsWith("]")) { + names.push(segment.slice(4, -1)); + } else if (segment.startsWith("[") && segment.endsWith("]")) { + names.push(segment.slice(1, -1)); + } + } + return names; +} + +function resolveTargetParamsFromHref(targetHref: string | undefined): RouteParams | null { + if (isServer || !targetHref) return null; + const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__; + if (!routes) return null; + + let url: URL; + try { + url = new URL(targetHref, window.location.href); + } catch { + return null; + } + if (url.origin !== window.location.origin) return null; + + const routeHref = `${stripBasePath(url.pathname, __basePath)}${url.search}`; + return matchRouteWithTrie(routeHref, routes, retainedLayoutRouteTrieCache)?.params ?? null; +} + +function canRetainLayoutForTargetParams( + layoutId: string, + snapshot: CachedRscResponse, + targetParams: RouteParams | null, +): boolean { + const paramNames = getLayoutParamNames(layoutId); + if (paramNames === null) return false; + if (paramNames.length === 0) return true; + if (targetParams === null) return false; + + const sourceParams = parseParamsHeader(snapshot.paramsHeader); + if (sourceParams === null) return false; + return paramNames.every((name) => areParamValuesEqual(sourceParams[name], targetParams[name])); +} + +function parseLayoutIdsHeader(value: string | null): readonly string[] | undefined { + if (!value) return undefined; + const ids: string[] = []; + const seen = new Set(); + for (const token of value.trim().split(/\s+/)) { + if (!token || seen.has(token)) continue; + if (AppElementsWire.parseElementKey(token)?.kind !== "layout") continue; + seen.add(token); + ids.push(token); + } + return ids.length === 0 ? undefined : ids; +} + function isStaleTimeSeconds(value: unknown): value is number { return ( typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 @@ -507,9 +629,15 @@ function resolvePrefetchedRscResponseExpiresAt( if (isCacheExpiresAt(cached.expiresAt)) { return cached.expiresAt; } - // Next's runtime-prefetch stale time comes from the completed cacheLife - // claim. `staleTimes.dynamic` independently bounds visited/BFCache reuse and - // is only the prefetch fallback when the render made no cacheLife claim. + // Automatic prefetches must respect the route's dynamic bound even when a + // nested cache scope contributed a longer cacheLife claim. Explicit full + // prefetches (and complete runtime instant shells) opt into the static floor. + const dynamicSeconds = isStaleTimeSeconds(cached.dynamicStaleTimeSeconds) + ? cached.dynamicStaleTimeSeconds + : undefined; + if (honorDynamicStaleTime && dynamicSeconds !== undefined) { + return timestamp + dynamicSeconds * 1000; + } // A full prefetch uses the static window while a provisional cacheLife claim // is unresolved. If the render completes with a real cacheLife or dynamic // bound, completion metadata replaces the provisional marker below. @@ -520,12 +648,9 @@ function resolvePrefetchedRscResponseExpiresAt( if (serverSeconds !== undefined) { return timestamp + serverSeconds * 1000; } - const seconds = isStaleTimeSeconds(cached.dynamicStaleTimeSeconds) - ? cached.dynamicStaleTimeSeconds - : undefined; // No signal: the static prefetch window, floored like Next's // `STATIC_STALETIME_MS = getStaleTimeMs(config)`. - if (seconds === undefined) { + if (dynamicSeconds === undefined) { return timestamp + Math.max(fallbackTtlMs, MIN_PREFETCH_STALE_TIME_MS); } // An automatic prefetch takes a dynamic render's bound verbatim, including @@ -534,12 +659,12 @@ function resolvePrefetchedRscResponseExpiresAt( // reuse. `prefetch={true}` uses Next's Full fetch strategy, so a config // dynamic bound of zero selects STATIC_STALETIME_MS. Nonzero completed // dynamic bounds keep their existing per-page expiry. - return honorDynamicStaleTime - ? timestamp + seconds * 1000 - : timestamp + - (seconds === 0 - ? Math.max(fallbackTtlMs, MIN_PREFETCH_STALE_TIME_MS) - : Math.max(seconds * 1000, MIN_PREFETCH_STALE_TIME_MS)); + return ( + timestamp + + (dynamicSeconds === 0 + ? Math.max(fallbackTtlMs, MIN_PREFETCH_STALE_TIME_MS) + : Math.max(dynamicSeconds * 1000, MIN_PREFETCH_STALE_TIME_MS)) + ); } function resolvePrefetchCacheEntryExpiresAt(entry: PrefetchCacheEntry): number { @@ -590,6 +715,98 @@ function parsePrefetchCacheKey(cacheKey: string): { }; } +function normalizeRetainedPrefetchTargetUrl(href: string | undefined): string | null { + if (isServer || !href) return null; + try { + const url = new URL(href, window.location.href); + stripRscCacheBustingSearchParam(url); + return `${url.pathname}${url.search}`; + } catch { + return null; + } +} + +export function getRetainedPrefetchLayoutClaims( + options: { + interceptionContext?: string | null; + mountedSlotsHeader?: string | null; + targetHref?: string; + targetParams?: RouteParams | null; + } = {}, +): readonly RetainedPrefetchLayoutClaim[] { + const claims: RetainedPrefetchLayoutClaim[] = []; + const seenLayouts = new Set(); + const seenEntries = new Set(); + const now = Date.now(); + const targetParams = options.targetParams ?? resolveTargetParamsFromHref(options.targetHref); + const normalizedTargetUrl = normalizeRetainedPrefetchTargetUrl(options.targetHref); + const interceptionContext = options.interceptionContext ?? null; + const mountedSlotsHeader = options.mountedSlotsHeader ?? null; + + for (const [cacheKey, entry] of getPrefetchCache()) { + if (seenEntries.has(entry)) continue; + seenEntries.add(entry); + if (entry.retainedLayoutProvider !== true || entry.cacheForNavigation === false) continue; + if (entry.outcome !== "cache-seeded" || !entry.snapshot) continue; + if (resolvePrefetchCacheEntryExpiresAt(entry) <= now) continue; + + const source = parsePrefetchCacheKey(cacheKey); + if (source.interceptionContext !== interceptionContext) continue; + if (!isPrefetchCacheEntryCompatibleWithMountedSlots(entry, mountedSlotsHeader)) continue; + if ( + normalizedTargetUrl !== null && + normalizeRscCacheLookupUrl(source.rscUrl) === normalizedTargetUrl + ) { + continue; + } + + for (const layoutId of entry.snapshot.layoutIds ?? []) { + if (seenLayouts.has(layoutId)) continue; + if (!canRetainLayoutForTargetParams(layoutId, entry.snapshot, targetParams)) continue; + seenLayouts.add(layoutId); + claims.push({ layoutId, provider: entry }); + } + } + + return claims; +} + +export function getLiveRetainedPrefetchLayoutClaims( + claims: readonly RetainedPrefetchLayoutClaim[] | undefined, + requiredLayoutIds?: ReadonlySet, +): readonly RetainedPrefetchLayoutClaim[] { + if (!claims || claims.length === 0) return []; + const liveEntries = new Set(getPrefetchCache().values()); + const seenLayouts = new Set(); + const now = Date.now(); + return claims.filter((claim) => { + if (seenLayouts.has(claim.layoutId)) return false; + if (requiredLayoutIds && !requiredLayoutIds.has(claim.layoutId)) return false; + const provider = claim.provider; + if (!liveEntries.has(provider)) return false; + if (provider.retainedLayoutProvider !== true || provider.cacheForNavigation === false) { + return false; + } + if (provider.outcome !== "cache-seeded" || !provider.snapshot) return false; + if (resolvePrefetchCacheEntryExpiresAt(provider) <= now) return false; + if (!provider.snapshot.layoutIds?.includes(claim.layoutId)) return false; + seenLayouts.add(claim.layoutId); + return true; + }); +} + +export function getRetainedPrefetchLayoutIdsHeader( + options: { + interceptionContext?: string | null; + mountedSlotsHeader?: string | null; + targetHref?: string; + targetParams?: RouteParams | null; + } = {}, +): string | null { + const ids = getRetainedPrefetchLayoutClaims(options).map((claim) => claim.layoutId); + return ids.length === 0 ? null : ids.join(" "); +} + function isPrefetchCacheEntryCompatibleWithMountedSlots( entry: PrefetchCacheEntry, mountedSlotsHeader: string | null, @@ -607,6 +824,38 @@ function isPrefetchCacheEntryCompatibleWithMountedSlots( return (entry.snapshot?.mountedSlotsHeader ?? null) === mountedSlotsHeader; } +function arePrefetchLayoutDependenciesSatisfied( + cache: Map, + entry: PrefetchCacheEntry, +): boolean { + const dependencies = entry.retainedLayoutDependencies; + if (!dependencies || dependencies.length === 0) return true; + + const liveEntries = new Set(cache.values()); + const now = Date.now(); + return dependencies.every( + (provider) => + liveEntries.has(provider) && + provider.retainedLayoutProvider === true && + provider.cacheForNavigation !== false && + provider.outcome === "cache-seeded" && + provider.snapshot !== undefined && + resolvePrefetchCacheEntryExpiresAt(provider) > now, + ); +} + +function isPrefetchCacheEntryReusableForNavigation( + cache: Map, + entry: PrefetchCacheEntry, + mountedSlotsHeader: string | null, +): boolean { + return ( + entry.cacheForNavigation !== false && + isPrefetchCacheEntryCompatibleWithMountedSlots(entry, mountedSlotsHeader) && + arePrefetchLayoutDependenciesSatisfied(cache, entry) + ); +} + function findPrefetchCacheEntryForNavigation( rscUrl: string, interceptionContext: string | null, @@ -621,8 +870,7 @@ function findPrefetchCacheEntryForNavigation( const exactEntry = cache.get(exactCacheKey); if ( exactEntry && - exactEntry.cacheForNavigation !== false && - isPrefetchCacheEntryCompatibleWithMountedSlots(exactEntry, mountedSlotsHeader) + isPrefetchCacheEntryReusableForNavigation(cache, exactEntry, mountedSlotsHeader) ) { return { cacheKey: exactCacheKey, entry: exactEntry }; } @@ -636,13 +884,11 @@ function findPrefetchCacheEntryForNavigation( if (normalizedTargets.size === 0) return null; for (const [cacheKey, entry] of cache) { - if (entry.cacheForNavigation === false) continue; - const source = parsePrefetchCacheKey(cacheKey); if (source.interceptionContext !== interceptionContext) continue; const normalizedSource = normalizeRscCacheLookupUrl(source.rscUrl); if (normalizedSource === null || !normalizedTargets.has(normalizedSource)) continue; - if (!isPrefetchCacheEntryCompatibleWithMountedSlots(entry, mountedSlotsHeader)) continue; + if (!isPrefetchCacheEntryReusableForNavigation(cache, entry, mountedSlotsHeader)) continue; return { cacheKey, entry }; } @@ -986,6 +1232,20 @@ function deletePrefetchCacheEntry( clearPrefetchInvalidation(entry); entry.onInvalidateCallbacks = undefined; } + + const dependents: Array<[string, PrefetchCacheEntry]> = []; + const seen = new Set(); + for (const [dependentKey, dependentEntry] of cache) { + if (seen.has(dependentEntry)) continue; + seen.add(dependentEntry); + if (dependentEntry.retainedLayoutDependencies?.includes(entry)) { + dependents.push([dependentKey, dependentEntry]); + } + } + for (const [dependentKey, dependentEntry] of dependents) { + if (cache.get(dependentKey) !== dependentEntry) continue; + deletePrefetchCacheEntry(cache, prefetched, dependentKey, dependentEntry, notify); + } } export function discardLearningOnlyPrefetchCacheEntry( @@ -1002,7 +1262,12 @@ export function discardLearningOnlyPrefetchCacheEntry( // Map this loop is still iterating. const superseded: Array<[string, PrefetchCacheEntry]> = []; for (const [cacheKey, entry] of cache) { - if (entry.cacheForNavigation !== false || entry.prefetchKind !== "navigation") continue; + if ( + entry.cacheForNavigation !== false || + (entry.prefetchKind !== "navigation" && entry.prefetchKind !== "instant-shell") + ) { + continue; + } const source = parsePrefetchCacheKey(cacheKey); if (source.interceptionContext !== interceptionContext) continue; if (normalizeRscCacheLookupUrl(source.rscUrl) !== normalizedTarget) continue; @@ -1066,14 +1331,16 @@ function attachPrefetchInvalidationToEntry( } } -function attachPrefetchInvalidationCallback( +export function attachPrefetchInvalidationCallback( cacheKey: string, onInvalidate: (() => void) | undefined, -): void { - if (onInvalidate === undefined) return; + expectedEntry?: PrefetchCacheEntry, +): boolean { + if (onInvalidate === undefined) return false; const entry = getPrefetchCache().get(cacheKey); - if (!entry) return; + if (!entry || (expectedEntry !== undefined && entry !== expectedEntry)) return false; attachPrefetchInvalidationToEntry(cacheKey, entry, onInvalidate); + return true; } export function invalidatePrefetchCache(): void { @@ -1119,6 +1386,12 @@ export function seedPrefetchResponseSnapshot( expiresAt: resolveCachedRscResponseExpiresAt(timestamp, snapshot, fallbackTtlMs), mountedSlotsHeader, outcome: "cache-seeded", + preparedElements: snapshot.preparedElements, + retainedLayoutDependencies: [ + ...new Set((snapshot.retainedLayoutClaims ?? []).map((claim) => claim.provider)), + ], + retainedLayoutClaims: snapshot.retainedLayoutClaims, + retainedLayoutProvider: snapshot.retainedLayoutProvider === true, size: snapshot.buffer.byteLength, snapshot, timestamp, @@ -1240,6 +1513,7 @@ export function createCachedRscResponseSnapshot( ...(completedDynamicStaleTimeSeconds !== undefined ? { completedDynamicStaleTimeSeconds } : {}), contentType: response.headers.get("content-type") ?? VINEXT_RSC_CONTENT_TYPE, ...(dynamicStaleTimeSeconds !== undefined ? { dynamicStaleTimeSeconds } : {}), + layoutIds: parseLayoutIdsHeader(response.headers.get(VINEXT_RSC_LAYOUT_IDS_HEADER)), mountedSlotsHeader: response.headers.get(VINEXT_MOUNTED_SLOTS_HEADER), paramsHeader: response.headers.get(VINEXT_PARAMS_HEADER), renderedPathAndSearch: parseRenderedPathAndSearchHeader( @@ -1324,6 +1598,9 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp if (cached.paramsHeader != null) { headers.set(VINEXT_PARAMS_HEADER, cached.paramsHeader); } + if (cached.layoutIds && cached.layoutIds.length > 0) { + headers.set(VINEXT_RSC_LAYOUT_IDS_HEADER, cached.layoutIds.join(" ")); + } if (cached.renderedPathAndSearch != null) { headers.set( VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, @@ -1349,12 +1626,17 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp */ export async function prepareNavigationPrefetchSnapshot( snapshot: CachedRscResponse, + retainedLayoutClaims: readonly RetainedPrefetchLayoutClaim[] = snapshot.retainedLayoutClaims ?? + [], ): Promise { const preparePrefetchResponse = getNavigationRuntime()?.functions.preparePrefetchResponse; if (!preparePrefetchResponse) { throw new Error("App Router prefetch preparation is unavailable"); } - return (await preparePrefetchResponse(restoreRscResponse(snapshot))) as AppElements; + return (await preparePrefetchResponse( + restoreRscResponse(snapshot), + retainedLayoutClaims, + )) as AppElements; } /** @@ -1370,6 +1652,7 @@ export async function fetchRouteTreeGatedPrefetch(options: { headers: Headers; interceptionContext: string | null; mountedSlotsHeader: string | null; + retainedLayoutClaims?: readonly RetainedPrefetchLayoutClaim[]; }): Promise { const { fetchFullRscPayload, @@ -1378,6 +1661,7 @@ export async function fetchRouteTreeGatedPrefetch(options: { headers, interceptionContext, mountedSlotsHeader, + retainedLayoutClaims, } = options; const routeTreeHeaders = new Headers(headers); routeTreeHeaders.set(NEXT_ROUTER_PREFETCH_HEADER, "1"); @@ -1397,6 +1681,7 @@ export async function fetchRouteTreeGatedPrefetch(options: { cacheForNavigation: false, optimisticRouteShell: false, prefetchKind: "route-tree", + retainedLayoutClaims, }, ); routeTreeEntry = getFreshPrefetchCacheEntry(routeTreeCacheKey); @@ -1435,9 +1720,16 @@ export function prefetchRscResponse( cacheForNavigation?: boolean; fallbackTtlMs?: number; honorDynamicStaleTime?: boolean; + instantShell?: boolean; + promoteCompleteInstantShell?: boolean; optimisticRouteShell?: boolean; prefetchKind?: PrefetchCacheKind; - prepareSnapshot?: (snapshot: CachedRscResponse) => Promise; + prepareSnapshot?: ( + snapshot: CachedRscResponse, + retainedLayoutClaims?: readonly RetainedPrefetchLayoutClaim[], + ) => Promise; + retainedLayoutClaims?: readonly RetainedPrefetchLayoutClaim[]; + retainedLayoutProvider?: boolean; searchAgnosticShell?: boolean; } = {}, ): void { @@ -1453,13 +1745,21 @@ export function prefetchRscResponse( const entry: PrefetchCacheEntry = { cacheForNavigation: behavior.cacheForNavigation ?? true, cacheKeys: new Set([cacheKey]), + instantShell: behavior.instantShell === true, mountedSlotsHeader, optimisticRouteShell: behavior.optimisticRouteShell === true, outcome: "pending", prefetchKind: behavior.prefetchKind ?? - (behavior.optimisticRouteShell === true ? "loading-shell" : "navigation"), + (behavior.instantShell === true + ? "instant-shell" + : behavior.optimisticRouteShell === true + ? "loading-shell" + : "navigation"), searchAgnosticShell: behavior.searchAgnosticShell === true, + retainedLayoutDependencies: [], + retainedLayoutClaims: behavior.retainedLayoutClaims ?? [], + retainedLayoutProvider: behavior.retainedLayoutProvider === true, timestamp: now, }; addPrefetchInvalidationCallback(entry, options?.onInvalidate); @@ -1468,10 +1768,28 @@ export function prefetchRscResponse( entry.pending = fetchPromise .then(async (response) => { if (response.ok) { + const isPartialInstantShell = response.headers.get(VINEXT_RSC_PARTIAL_SHELL_HEADER) === "1"; + if (isPartialInstantShell) { + entry.cacheForNavigation = false; + entry.partialSuspenseShell = true; + } else if (entry.instantShell === true && behavior.promoteCompleteInstantShell === true) { + entry.cacheForNavigation = true; + entry.retainedLayoutProvider = true; + } const snapshot = await snapshotRscResponse(response); if (cache.get(cacheKey) !== entry) return; const previousSize = getPrefetchCacheEntrySize(entry); - entry.snapshot = snapshot; + const physicallyPresentLayouts = new Set(snapshot.layoutIds ?? []); + entry.retainedLayoutClaims = (behavior.retainedLayoutClaims ?? []).filter( + (claim) => !physicallyPresentLayouts.has(claim.layoutId), + ); + entry.retainedLayoutDependencies = []; + entry.snapshot = { + ...snapshot, + ...(entry.retainedLayoutClaims.length > 0 + ? { retainedLayoutClaims: entry.retainedLayoutClaims } + : {}), + }; entry.size = snapshot.buffer.byteLength; adjustPrefetchCacheByteSize(cache, entry.size - previousSize); entry.expiresAt = resolvePrefetchedRscResponseExpiresAt( @@ -1482,14 +1800,60 @@ export function prefetchRscResponse( // data and is never navigation-consumable. Keep it on the prefetch // freshness lattice so another search string can reuse the shell; // the later navigation response still honors dynamicStaleTime. - behavior.honorDynamicStaleTime !== false && behavior.searchAgnosticShell !== true, + behavior.promoteCompleteInstantShell === true && !isPartialInstantShell + ? false + : behavior.honorDynamicStaleTime !== false && behavior.searchAgnosticShell !== true, ); - if (behavior.prepareSnapshot) { + const prepareSnapshot = + behavior.prepareSnapshot ?? + (behavior.promoteCompleteInstantShell === true && !isPartialInstantShell + ? prepareNavigationPrefetchSnapshot + : undefined); + if (prepareSnapshot) { try { - const preparedElements = await behavior.prepareSnapshot(snapshot); + const preparedElements = await prepareSnapshot( + entry.snapshot, + entry.retainedLayoutClaims, + ); if (cache.get(cacheKey) !== entry) return; entry.preparedElements = preparedElements; + let usedClaims: readonly RetainedPrefetchLayoutClaim[] | null = null; + try { + const skippedLayoutIds = new Set( + AppElementsWire.readMetadata(preparedElements).skippedLayoutIds, + ); + usedClaims = entry.retainedLayoutClaims.filter((claim) => + skippedLayoutIds.has(claim.layoutId), + ); + } catch { + // Custom/internal preparation callbacks may return a partial test + // value rather than decoded AppElements. Without wire metadata, + // do not invent dependencies from the advertised claim superset. + } + if (usedClaims !== null) { + entry.retainedLayoutClaims = usedClaims; + entry.retainedLayoutDependencies = [ + ...new Set(usedClaims.map((claim) => claim.provider)), + ]; + const { + retainedLayoutClaims: _advertisedRetainedLayoutClaims, + ...snapshotWithoutClaims + } = entry.snapshot; + entry.snapshot = + usedClaims.length > 0 + ? { ...snapshotWithoutClaims, retainedLayoutClaims: usedClaims } + : snapshotWithoutClaims; + } } catch { + if ((entry.retainedLayoutClaims?.length ?? 0) > 0) { + // A response that omitted layouts is only reusable after those + // exact providers have been resolved successfully. If a provider + // expired or was evicted during the request, discard the entry so + // navigation performs a fresh RSC request instead of consuming an + // incomplete payload and falling back to a document reload. + deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, false); + return; + } // Preparation is an acceleration only. Keep the buffered response // so navigation can decode it through the normal path. } @@ -1643,6 +2007,7 @@ function consumeMatchedPrefetchResponse( retainPrefetchInvalidationAfterConsume(entry); deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, false); const snapshot = entry.snapshot; + const retainedLayoutProvider = entry.retainedLayoutProvider === true; // Only synthesize `expiresAt` onto the returned snapshot when the entry (or // its snapshot) already carried one. Entries that never had an explicit // expiry must round-trip unchanged so callers/tests can assert the raw @@ -1652,10 +2017,15 @@ function consumeMatchedPrefetchResponse( ...snapshot, expiresAt: resolvePrefetchCacheEntryExpiresAt(entry), ...(entry.preparedElements ? { preparedElements: entry.preparedElements } : {}), + ...(retainedLayoutProvider ? { retainedLayoutProvider: true } : {}), }; } - return entry.preparedElements - ? { ...snapshot, preparedElements: entry.preparedElements } + return entry.preparedElements || retainedLayoutProvider + ? { + ...snapshot, + ...(entry.preparedElements ? { preparedElements: entry.preparedElements } : {}), + ...(retainedLayoutProvider ? { retainedLayoutProvider: true } : {}), + } : snapshot; } @@ -2774,10 +3144,20 @@ const _appRouter: AppRouterInstance = { // or mounted-slot key. const interceptionContext = getPrefetchInterceptionContext(fullHref); const mountedSlotsHeader = getMountedSlotsHeader(); + const retainedLayoutClaims = getRetainedPrefetchLayoutClaims({ + interceptionContext, + mountedSlotsHeader, + targetHref: fullHref, + }); + const retainedPrefetchLayoutsHeader = + retainedLayoutClaims.length === 0 + ? null + : retainedLayoutClaims.map((claim) => claim.layoutId).join(" "); const headers = createAppPrefetchRequestHeaders({ fetchPriority: "low", interceptionContext, mountedSlotsHeader: mountedSlotsHeader || null, + retainedPrefetchLayoutsHeader, }); const setup = beginPrefetchSetup(fullHref); void (async () => { @@ -2826,9 +3206,17 @@ const _appRouter: AppRouterInstance = { await import("./internal/app-route-prefetch-policy.js"); const policy = kind === "full" - ? resolveFullAppRoutePrefetch() + ? resolveFullAppRoutePrefetch(rewrittenPrefetchHref ?? fullHref) : resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? fullHref); const reusable = policy.shouldPrefetch && policy.cacheForNavigation; + if (policy.prefetchInstantShell) { + headers.set( + VINEXT_RSC_RENDER_MODE_HEADER, + policy.prefetchInstantShell === "static" + ? APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL + : APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + ); + } const requiresRouteTreePrefetch = policy.requiresRouteTreePrefetch === true; // The call-time header snapshot defaults to AUTO/learning semantics. // A full reusable prefetch is the one policy that suppresses this header. @@ -2874,9 +3262,19 @@ const _appRouter: AppRouterInstance = { ) { return; } - } else if (hasFreshLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext)) { - attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate); - return; + } else { + if (hasFreshLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext)) { + attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate); + return; + } + if ( + hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader, { + additionalRscUrls, + onInvalidate: options?.onInvalidate, + }) + ) { + return; + } } prefetched.add(cacheKey); const fetchFullRscPayload = () => @@ -2909,6 +3307,7 @@ const _appRouter: AppRouterInstance = { headers, interceptionContext, mountedSlotsHeader, + retainedLayoutClaims, }) : fetchFullRscPayload(); prefetchRscResponse( @@ -2928,11 +3327,16 @@ const _appRouter: AppRouterInstance = { optimisticRouteShell: false, prefetchKind: "navigation", prepareSnapshot: prepareNavigationPrefetchSnapshot, + retainedLayoutClaims, + retainedLayoutProvider: kind === "full", } : { cacheForNavigation: false, - optimisticRouteShell: true, - prefetchKind: "navigation", + instantShell: policy.prefetchInstantShell !== undefined, + promoteCompleteInstantShell: policy.prefetchInstantShell === "runtime", + optimisticRouteShell: !policy.prefetchInstantShell, + prefetchKind: policy.prefetchInstantShell ? "instant-shell" : "navigation", + retainedLayoutClaims, }, ); })() diff --git a/packages/vinext/src/shims/server.ts b/packages/vinext/src/shims/server.ts index 10aaeb6228..c5400f5fbd 100644 --- a/packages/vinext/src/shims/server.ts +++ b/packages/vinext/src/shims/server.ts @@ -1277,6 +1277,11 @@ export async function connection(): Promise { markRenderRequestApiUsage("connection"); throwIfInsideCacheScope("connection()"); markDynamicUsage(); + const { suspendInstantPrefetchConnection } = await import("./instant-prefetch-shell.js"); + const pendingInstantShell = suspendInstantPrefetchConnection(); + if (pendingInstantShell) { + await pendingInstantShell; + } const pendingProbe = suspendConnectionProbe(); if (pendingProbe) { await pendingProbe; diff --git a/packages/vinext/src/shims/thenable-params.ts b/packages/vinext/src/shims/thenable-params.ts index 520843513e..45c17337b8 100644 --- a/packages/vinext/src/shims/thenable-params.ts +++ b/packages/vinext/src/shims/thenable-params.ts @@ -2,6 +2,7 @@ import { createPprFallbackShellSuspensePromiseForState, getPprFallbackShellState, } from "./ppr-fallback-shell.js"; +import { suspendStaticInstantPrefetchRequestData } from "./instant-prefetch-shell.js"; function hasParamProperty>(obj: T, prop: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(obj, prop); @@ -72,6 +73,11 @@ export type ThenableParamsObserver = Readonly<{ observeReactPromiseStatus?: boolean; }>; +export type ThenableParamsOptions = Readonly<{ + /** Client-reference props are transport data, not a server request-data read. */ + suspendStaticInstantShell?: boolean; +}>; + function observeParamKeys( observer: ThenableParamsObserver | undefined, keys: readonly string[], @@ -114,6 +120,7 @@ function createResolvedParamsProxy>( fallbackParamNames: ReadonlySet | null, observer: ThenableParamsObserver | undefined, getFallbackShellPromise: () => Promise | null, + getStaticInstantShellPromise: () => Promise | null, ): T { if (!fallbackParamNames || fallbackParamNames.size === 0) { return plain; @@ -128,6 +135,8 @@ function createResolvedParamsProxy>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { if (isFallbackParam(prop)) { @@ -143,6 +152,8 @@ function createResolvedParamsProxy>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { if (isFallbackParam(prop)) { @@ -170,6 +181,8 @@ function createResolvedParamsProxy>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; return ( Reflect.has(plain, prop) || (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) @@ -177,6 +190,8 @@ function createResolvedParamsProxy>( }, ownKeys() { observeReadableParamKeys(observer, plain); + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; return Reflect.ownKeys(plain).filter((prop) => !isWellKnownProperty(prop)); }, }; @@ -199,6 +214,7 @@ function createThenableParamsProxy>( export function makeThenableParams>( obj: T, observer?: ThenableParamsObserver, + options?: ThenableParamsOptions, ): ThenableParams { const plain = { ...obj }; const fallbackShellState = getPprFallbackShellState(); @@ -242,11 +258,17 @@ export function makeThenableParams>( return fallbackShellPromise; } + function getStaticInstantShellPromise(): Promise | null { + if (options?.suspendStaticInstantShell === false) return null; + return suspendStaticInstantPrefetchRequestData("params"); + } + const resolvedParams = createResolvedParamsProxy( plain, fallbackParamNames, observer, getFallbackShellPromise, + getStaticInstantShellPromise, ); const promise = Promise.resolve(resolvedParams); @@ -267,6 +289,11 @@ export function makeThenableParams>( if (!fallbackParamNames) { observeAllParamKeys(observer, plain); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) { + const continuation = Reflect.get(instantShellPromise, prop); + return Reflect.apply(continuation, instantShellPromise, args); + } return Reflect.apply(value, target, args); }; } @@ -278,6 +305,8 @@ export function makeThenableParams>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { if (isFallbackParam(prop)) { @@ -294,6 +323,8 @@ export function makeThenableParams>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { if (isFallbackParam(prop)) { @@ -321,6 +352,8 @@ export function makeThenableParams>( if (typeof prop === "string" && !isWellKnownProperty(prop)) { observeParamKeys(observer, [prop]); } + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; return ( Reflect.has(target, prop) || (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) @@ -328,6 +361,8 @@ export function makeThenableParams>( }, ownKeys() { observeReadableParamKeys(observer, plain); + const instantShellPromise = getStaticInstantShellPromise(); + if (instantShellPromise) throw instantShellPromise; return Reflect.ownKeys(plain).filter((prop) => !isWellKnownProperty(prop)); }, }; diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 6534e063b5..5057eea5dc 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -12,6 +12,7 @@ import { APP_INTERCEPTION_CONTEXT_KEY, APP_LAYOUT_IDS_KEY, APP_LAYOUT_FLAGS_KEY, + APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_BFCACHE_SEGMENT_IDENTITIES_KEY, @@ -85,6 +86,7 @@ describe("AppElementsWire", () => { rootLayoutTreePath: "/", routeId: "route:/photos/42\0/feed", bfcacheSegmentIdentities: {}, + retainedOnlySkippedLayoutIds: [], skippedLayoutIds: [], slotBindings: [], sourcePage: null, @@ -349,6 +351,7 @@ describe("AppElementsWire", () => { rootLayoutTreePath: "/", routeId: "route:/dashboard", bfcacheSegmentIdentities: {}, + retainedOnlySkippedLayoutIds: [], skippedLayoutIds: [], slotBindings: [], sourcePage: null, @@ -684,6 +687,20 @@ describe("app elements payload helpers", () => { ).toThrow(message); }); + it("rejects retained-only skip metadata outside the complete skip set", () => { + expect(() => + readAppElementsMetadata({ + ...normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + }), + [APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY]: ["layout:/dashboard"], + }), + ).toThrow( + "[vinext] Invalid __retainedOnlySkippedLayoutIds in App Router payload: expected subset of __skippedLayoutIds", + ); + }); + it.each([ { label: "non-array", @@ -1063,7 +1080,37 @@ describe("buildOutgoingAppPayload", () => { expect(result[APP_ROUTE_KEY]).toBe("route:/dashboard"); expect(result[APP_ROOT_LAYOUT_KEY]).toBe("/"); expect(result[APP_SKIPPED_LAYOUT_IDS_KEY]).toEqual(["layout:/dashboard"]); + expect(result[APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY]).toBeUndefined(); expect(AppElementsWire.readMetadata(result).skippedLayoutIds).toEqual(["layout:/dashboard"]); + expect(AppElementsWire.readMetadata(result).retainedOnlySkippedLayoutIds).toEqual([]); + } + }); + + it("marks retained-only omissions separately from the complete skipped-layout set", () => { + const result = buildOutgoingAppPayload({ + element: { + [APP_ROUTE_KEY]: "route:/shared/two", + [APP_ROOT_LAYOUT_KEY]: "/", + "layout:/shared": "shared-layout", + "page:/shared/two": "page-two", + }, + layoutFlags: { "layout:/shared": "d" }, + skipDisposition: { + code: "SKIP_RETAINED_PREFETCH_LAYOUTS", + enabled: true, + mode: "skipRetainedPrefetchLayouts", + retainedOnlySkippedEntryIds: ["layout:/shared"], + skippedEntryIds: ["layout:/shared"], + }, + }); + + expect(isAppElementsRecord(result)).toBe(true); + if (isAppElementsRecord(result)) { + expect(result[APP_SKIPPED_LAYOUT_IDS_KEY]).toEqual(["layout:/shared"]); + expect(result[APP_RETAINED_ONLY_SKIPPED_LAYOUT_IDS_KEY]).toEqual(["layout:/shared"]); + expect(AppElementsWire.readMetadata(result).retainedOnlySkippedLayoutIds).toEqual([ + "layout:/shared", + ]); } }); diff --git a/tests/app-instant-prefetch-segments.test.ts b/tests/app-instant-prefetch-segments.test.ts new file mode 100644 index 0000000000..1fdf1af5b9 --- /dev/null +++ b/tests/app-instant-prefetch-segments.test.ts @@ -0,0 +1,68 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import { + resolveAppInstantPrefetchSegmentStages, + wrapAppInstantPrefetchSegment, +} from "../packages/vinext/src/server/app-instant-prefetch-segments.js"; +import { + createInstantPrefetchShellState, + runWithInstantPrefetchShellState, + suspendStaticInstantPrefetchRequestData, +} from "../packages/vinext/src/shims/instant-prefetch-shell.js"; + +describe("app instant prefetch segment stages", () => { + // Ported from Next.js: + // test/e2e/app-dir/segment-cache/prefetch-layout-sharing/prefetch-layout-sharing.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/segment-cache/prefetch-layout-sharing/prefetch-layout-sharing.test.ts + it("starts a new static subtree below a retained runtime layout", () => { + const runtime = { unstable_instant: { prefetch: "runtime" } }; + const staticallyConfigured = { unstable_instant: { prefetch: "static" } }; + + expect( + resolveAppInstantPrefetchSegmentStages({ + layouts: [runtime], + page: {}, + }), + ).toEqual({ layouts: ["runtime"], page: "runtime" }); + expect( + resolveAppInstantPrefetchSegmentStages({ + layouts: [runtime], + page: staticallyConfigured, + retainedLayouts: new Set([0]), + }), + ).toEqual({ layouts: ["static"], page: "static" }); + }); + + it("keeps a default-static sub-layout outside an explicitly runtime page", () => { + const stages = resolveAppInstantPrefetchSegmentStages({ + layouts: [{ unstable_instant: { prefetch: "runtime" } }, {}], + page: { unstable_instant: { prefetch: "runtime" } }, + retainedLayouts: new Set([0]), + }); + + expect(stages).toEqual({ layouts: ["static", "static"], page: "runtime" }); + }); + + it("applies the owning segment stage to nested server components", () => { + function RequestDataProbe() { + const suspended = suspendStaticInstantPrefetchRequestData("cookies()") !== null; + return createElement("span", null, suspended ? "static" : "runtime"); + } + function Segment() { + return createElement("div", null, createElement(RequestDataProbe)); + } + + const runtimeShell = createInstantPrefetchShellState("/test", "runtime"); + const staticHtml = runWithInstantPrefetchShellState(runtimeShell, () => + renderToStaticMarkup(wrapAppInstantPrefetchSegment(createElement(Segment), "static")), + ); + expect(staticHtml).toBe("
static
"); + + const staticShell = createInstantPrefetchShellState("/test", "static"); + const runtimeHtml = runWithInstantPrefetchShellState(staticShell, () => + renderToStaticMarkup(wrapAppInstantPrefetchSegment(createElement(Segment), "runtime")), + ); + expect(runtimeHtml).toBe("
runtime
"); + }); +}); diff --git a/tests/app-optimistic-routing.test.ts b/tests/app-optimistic-routing.test.ts index b8366fb1cb..7cc55e7268 100644 --- a/tests/app-optimistic-routing.test.ts +++ b/tests/app-optimistic-routing.test.ts @@ -8,6 +8,8 @@ import { import { createOptimisticRouteElements, createOptimisticRouteTemplate, + fillRetainedPrefetchLayoutsFromElementSources, + getMissingRetainedPrefetchLayoutIds, getOptimisticPrefetchSourceKey, getOptimisticRouteTemplateKey, matchOptimisticRouteManifestRoute, @@ -167,6 +169,130 @@ function createSettingsLoadingShellElements(): AppElements { } describe("App Router optimistic routing", () => { + it("fills layouts omitted from a retained-prefetch payload", () => { + const routeId = AppElementsWire.encodeRouteId("/shared/two", null); + const sharedLayoutId = AppElementsWire.encodeLayoutId("/shared"); + const target: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [AppElementsWire.encodeLayoutId("/"), sharedLayoutId], + rootLayoutTreePath: "/", + routeId, + }), + [AppElementsWire.keys.skippedLayoutIds]: [sharedLayoutId], + [AppElementsWire.keys.retainedOnlySkippedLayoutIds]: [sharedLayoutId], + [routeId]: createElement("main", null, "Page two"), + }; + const retainedLayout = createElement("section", null, "Shared layout"); + const source: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [sharedLayoutId], + rootLayoutTreePath: "/shared", + routeId: AppElementsWire.encodeRouteId("/shared/one", null), + }), + [sharedLayoutId]: retainedLayout, + }; + + const filled = fillRetainedPrefetchLayoutsFromElementSources(target, [source]); + + expect(filled[sharedLayoutId]).toBe(retainedLayout); + expect(AppElementsWire.readMetadata(filled).skippedLayoutIds).toEqual([]); + expect(AppElementsWire.readMetadata(filled).retainedOnlySkippedLayoutIds).toEqual([]); + }); + + it("does not fill standard-reuse omissions from a retained-layout provider", () => { + const routeId = AppElementsWire.encodeRouteId("/shared/two", null); + const standardLayoutId = AppElementsWire.encodeLayoutId("/"); + const retainedLayoutId = AppElementsWire.encodeLayoutId("/shared"); + const target: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [standardLayoutId, retainedLayoutId], + rootLayoutTreePath: "/", + routeId, + }), + [AppElementsWire.keys.skippedLayoutIds]: [standardLayoutId, retainedLayoutId], + [AppElementsWire.keys.retainedOnlySkippedLayoutIds]: [retainedLayoutId], + [routeId]: createElement("main", null, "Page two"), + }; + const retainedLayout = createElement("section", null, "Shared layout"); + const source: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [standardLayoutId, retainedLayoutId], + rootLayoutTreePath: "/", + routeId: AppElementsWire.encodeRouteId("/shared/one", null), + }), + [standardLayoutId]: createElement("html", null, "Stale root layout"), + [retainedLayoutId]: retainedLayout, + }; + + const filled = fillRetainedPrefetchLayoutsFromElementSources(target, [source]); + + expect(Object.hasOwn(filled, standardLayoutId)).toBe(false); + expect(filled[retainedLayoutId]).toBe(retainedLayout); + expect(AppElementsWire.readMetadata(filled).skippedLayoutIds).toEqual([standardLayoutId]); + expect(AppElementsWire.readMetadata(filled).retainedOnlySkippedLayoutIds).toEqual([]); + }); + + it("does not require providers for standard-reuse-only skipped layouts", () => { + const retainedLayoutId = AppElementsWire.encodeLayoutId("/shared"); + const visibleLayoutId = AppElementsWire.encodeLayoutId("/"); + const routeId = AppElementsWire.encodeRouteId("/shared/two", null); + const elements: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [visibleLayoutId, retainedLayoutId], + rootLayoutTreePath: "/", + routeId, + }), + [AppElementsWire.keys.skippedLayoutIds]: [visibleLayoutId, retainedLayoutId], + [routeId]: createElement("main", null, "Page two"), + }; + + expect(getMissingRetainedPrefetchLayoutIds(elements)).toEqual([]); + }); + + it("throws when a retained-only skipped layout has no provider", () => { + const retainedLayoutId = AppElementsWire.encodeLayoutId("/shared"); + const routeId = AppElementsWire.encodeRouteId("/shared/two", null); + const elements: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [retainedLayoutId], + rootLayoutTreePath: "/", + routeId, + }), + [AppElementsWire.keys.skippedLayoutIds]: [retainedLayoutId], + [AppElementsWire.keys.retainedOnlySkippedLayoutIds]: [retainedLayoutId], + [routeId]: createElement("main", null, "Page two"), + }; + + expect(getMissingRetainedPrefetchLayoutIds(elements)).toEqual([retainedLayoutId]); + expect(() => fillRetainedPrefetchLayoutsFromElementSources(elements, [])).toThrow( + "Retained prefetch layout provider is no longer available", + ); + }); + + it("does not require a provider for standard-and-retained overlap", () => { + const sharedLayoutId = AppElementsWire.encodeLayoutId("/shared"); + const routeId = AppElementsWire.encodeRouteId("/shared/two", null); + const elements: AppElements = { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: [sharedLayoutId], + rootLayoutTreePath: "/", + routeId, + }), + [AppElementsWire.keys.skippedLayoutIds]: [sharedLayoutId], + [routeId]: createElement("main", null, "Page two"), + }; + + expect(getMissingRetainedPrefetchLayoutIds(elements)).toEqual([]); + expect(fillRetainedPrefetchLayoutsFromElementSources(elements, [])).toBe(elements); + }); + it("matches dynamic route params while keeping static siblings authoritative", () => { const routes = blogManifest(); @@ -283,6 +409,58 @@ describe("App Router optimistic routing", () => { expect(navigationPayload?.elements[pageId]).not.toBe(elements[pageId]); }); + it("preserves completed instant-shell page content only for the exact href", () => { + const routeManifest = blogManifest(); + const elements = createBlogElements(); + const template = createOptimisticRouteTemplate({ + allowLoadingShell: true, + basePath: "", + elements, + href: "/blog/post-1.rsc?_rsc=instant", + interceptionContext: null, + mountedSlotsHeader: null, + preservePageElements: true, + routeManifest, + }); + if (template === null) throw new Error("Expected instant route template"); + + expect(template.concreteHrefKey).toBe("/blog/post-1"); + expect(template.pageElementIds).toEqual([]); + const templates = new Map([ + [ + getOptimisticRouteTemplateKey({ + concreteHrefKey: template.concreteHrefKey, + interceptionContext: null, + mountedSlotsHeader: null, + routeId: template.routeId, + }), + template, + ], + ]); + + const exact = resolveOptimisticNavigationPayload({ + basePath: "", + href: "/blog/post-1", + interceptionContext: null, + mountedSlotsHeader: null, + routeManifest, + templates, + }); + expect(exact?.elements[AppElementsWire.encodePageId("/blog/post-1", null)]).toEqual( + elements[AppElementsWire.encodePageId("/blog/post-1", null)], + ); + expect( + resolveOptimisticNavigationPayload({ + basePath: "", + href: "/blog/post-2", + interceptionContext: null, + mountedSlotsHeader: null, + routeManifest, + templates, + }), + ).toBeNull(); + }); + it("includes active parallel slot params in optimistic navigation payloads", () => { // Mirrors the immediate pre-dynamic-render assertion in Next.js: // test/e2e/app-dir/parallel-route-navigations/parallel-route-navigations.test.ts diff --git a/tests/app-page-element-builder.test.ts b/tests/app-page-element-builder.test.ts index 8c0481560f..282d87a207 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -30,7 +30,14 @@ import { } from "../packages/vinext/src/server/app-page-element-builder.js"; import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js"; import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; -import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, +} from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + createInstantPrefetchShellState, + runWithInstantPrefetchShellState, +} from "../packages/vinext/src/shims/instant-prefetch-shell.js"; // --------------------------------------------------------------------------- // Mocks @@ -986,6 +993,60 @@ describe("buildPageElements", () => { expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams"); }); + it("keeps client page params as transport data in static instant shells", async () => { + // Next.js omits server-provided params for client pages/segments when + // Cache Components are enabled. Vinext still transports the concrete + // values, but serializing those props must not count as a server request + // data read and cut the client boundary out of the static shell. + // Ported from Next.js: + // packages/next/src/server/app-render/create-component-tree.tsx + const ClientPage = Object.assign(() => null, { + $$typeof: Symbol.for("react.client.reference"), + }); + const route = createSyntheticRoute({ + page: createSyntheticPageModule(ClientPage), + layouts: [], + routeSegments: ["[slug]"], + pattern: "/[slug]", + }); + const shellState = createInstantPrefetchShellState("/hello", "static"); + + try { + await runWithInstantPrefetchShellState(shellState, async () => { + const base = createBaseOptions({ + params: { slug: "hello" }, + route, + routePath: "/hello", + searchParams: new URLSearchParams("view=grid"), + }); + const result = await buildPageElements({ + ...base, + pageRequest: { + ...base.pageRequest, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + }, + }); + const record = result as Record; + const pageElement = record["page:/hello"]; + if ( + !React.isValidElement<{ + params: Promise; + searchParams: Promise>; + }>(pageElement) + ) { + throw new Error("Expected client page element"); + } + + await expect(pageElement.props.params).resolves.toEqual({ slug: "hello" }); + await expect(pageElement.props.searchParams).resolves.toEqual({ view: "grid" }); + }); + + expect(shellState.hasDynamicBoundary).toBe(false); + } finally { + shellState.dynamicAbortController.abort(); + } + }); + it("attaches route-state slot bindings for active, default, and unmatched slots", async () => { function TestPage(): React.ReactNode { return React.createElement("div", null, "Hello"); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 7a1e737329..53c105750a 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import React from "react"; +import { readFileSync } from "node:fs"; import { APP_ARTIFACT_COMPATIBILITY_KEY, APP_LAYOUT_FLAGS_KEY, @@ -32,10 +33,14 @@ import { VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_RSC_COMPLETION_METADATA_HEADER, + VINEXT_RSC_LAYOUT_IDS_HEADER, VINEXT_STALE_TIME_PENDING_HEADER, } from "../packages/vinext/src/server/headers.js"; import { extractRscCompletionMetadata } from "../packages/vinext/src/server/rsc-completion-metadata.js"; -import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; +import type { + CachedAppPageValue, + IncrementalCacheValue, +} from "../packages/vinext/src/shims/cache.js"; import type { IsrWritePolicy } from "../packages/vinext/src/server/isr-cache.js"; import type { InitialNavigationCacheMetadata } from "../packages/vinext/src/server/app-ssr-stream.js"; import { @@ -48,6 +53,15 @@ import { runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, +} from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + suspendInstantPrefetchConnection, + suspendStaticInstantPrefetchRequestData, + trackInstantPrefetchShellCacheTask, +} from "../packages/vinext/src/shims/instant-prefetch-shell.js"; function captureRecord(value: ReactNode | AppOutgoingElements): Record { if (!isAppElementsRecord(value)) { @@ -408,6 +422,215 @@ describe("form state rendering", () => { }); describe("app page render lifecycle", () => { + it("loads the instant-shell runtime only inside the instant render branch", () => { + const source = readFileSync( + new URL("../packages/vinext/src/server/app-page-render.ts", import.meta.url), + "utf8", + ); + expect(source).not.toMatch(/from\s+["']vinext\/shims\/instant-prefetch-shell["']/); + expect(source).toContain('await import("vinext/shims/instant-prefetch-shell")'); + }); + + it("waits for cold private cache tasks before aborting a runtime instant shell", async () => { + const common = createCommonOptions(); + const cacheFill = createDeferred(); + let renderCount = 0; + const prerenderToReadableStream: NonNullable< + Parameters[0]["prerenderToReadableStream"] + > = vi.fn((_element, options) => { + renderCount++; + if (renderCount === 1) { + void trackInstantPrefetchShellCacheTask(() => cacheFill.promise, "private"); + } + void suspendInstantPrefetchConnection(); + + return new Promise<{ prelude: ReadableStream }>((resolve) => { + const finish = () => { + resolve({ prelude: createStream(["instant-shell"]) }); + }; + if (options.signal?.aborted) finish(); + else options.signal?.addEventListener("abort", finish, { once: true }); + }); + }); + + const responsePromise = renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + prerenderToReadableStream, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + }); + let responseSettled = false; + void responsePromise.then(() => { + responseSettled = true; + }); + + await vi.waitFor(() => expect(prerenderToReadableStream).toHaveBeenCalledTimes(1)); + expect(renderCount).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(responseSettled).toBe(false); + cacheFill.resolve(); + + const response = await responsePromise; + expect(prerenderToReadableStream).toHaveBeenCalledTimes(1); + await expect(response.text()).resolves.toBe("instant-shell"); + }); + + it("waits for a cold unstable_cache fill before aborting an instant shell", async () => { + // Regression coverage for Next.js: + // test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts + const { MemoryCacheHandler, setCacheHandler, unstable_cache } = + await import("../packages/vinext/src/shims/cache.js"); + const cacheFill = createDeferred(); + setCacheHandler(new MemoryCacheHandler()); + const cached = unstable_cache(async () => { + await cacheFill.promise; + return "filled"; + }, ["test:instant-shell-unstable-cache"]); + let cacheTask: Promise | null = null; + const common = createCommonOptions(); + const prerenderToReadableStream: NonNullable< + Parameters[0]["prerenderToReadableStream"] + > = vi.fn((_element, options) => { + cacheTask = cached(); + void suspendInstantPrefetchConnection(); + return new Promise<{ prelude: ReadableStream }>((resolve) => { + const finish = () => resolve({ prelude: createStream(["unstable-cache-shell"]) }); + if (options.signal?.aborted) finish(); + else options.signal?.addEventListener("abort", finish, { once: true }); + }); + }); + + try { + const responsePromise = renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + prerenderToReadableStream, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + }); + let responseSettled = false; + void responsePromise.then(() => { + responseSettled = true; + }); + + await vi.waitFor(() => expect(cacheTask).not.toBeNull()); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(responseSettled).toBe(false); + cacheFill.resolve(); + + await expect(cacheTask).resolves.toBe("filled"); + const response = await responsePromise; + await expect(response.text()).resolves.toBe("unstable-cache-shell"); + } finally { + cacheFill.resolve(); + await (cacheTask as Promise | null)?.catch(() => {}); + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("waits for a cold cached fetch before aborting an instant shell", async () => { + // Regression coverage for Next.js: + // test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts + const { MemoryCacheHandler, setCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { runWithFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js"); + const cacheWrite = createDeferred(); + class SlowWriteCacheHandler extends MemoryCacheHandler { + override async set( + key: string, + data: IncrementalCacheValue | null, + ctx?: Record, + ) { + await cacheWrite.promise; + return super.set(key, data, ctx); + } + } + setCacheHandler(new SlowWriteCacheHandler()); + let fetchTask: Promise | null = null; + const common = createCommonOptions(); + const prerenderToReadableStream: NonNullable< + Parameters[0]["prerenderToReadableStream"] + > = vi.fn((_element, options) => { + fetchTask = fetch("data:text/plain,cold-fetch", { cache: "force-cache" }); + void suspendInstantPrefetchConnection(); + return new Promise<{ prelude: ReadableStream }>((resolve) => { + const finish = () => resolve({ prelude: createStream(["cached-fetch-shell"]) }); + if (options.signal?.aborted) finish(); + else options.signal?.addEventListener("abort", finish, { once: true }); + }); + }); + + try { + const responsePromise = runWithFetchCache(() => + renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + prerenderToReadableStream, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + }), + ); + let responseSettled = false; + void responsePromise.then(() => { + responseSettled = true; + }); + + await vi.waitFor(() => expect(fetchTask).not.toBeNull()); + await expect(fetchTask).resolves.toBeInstanceOf(Response); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(responseSettled).toBe(false); + cacheWrite.resolve(); + + const response = await responsePromise; + await expect(response.text()).resolves.toBe("cached-fetch-shell"); + } finally { + cacheWrite.resolve(); + await (fetchTask as Promise | null)?.catch(() => {}); + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("stops static instant shells before request-time data", async () => { + const common = createCommonOptions(); + const prerenderToReadableStream: NonNullable< + Parameters[0]["prerenderToReadableStream"] + > = vi.fn((_element, options) => { + expect(suspendStaticInstantPrefetchRequestData("headers()")).toBeTruthy(); + return new Promise<{ prelude: ReadableStream }>((resolve) => { + const finish = () => resolve({ prelude: createStream(["static-instant-shell"]) }); + if (options.signal?.aborted) finish(); + else options.signal?.addEventListener("abort", finish, { once: true }); + }); + }); + + const response = await renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + prerenderToReadableStream, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + }); + + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + await expect(response.text()).resolves.toBe("static-instant-shell"); + }); + + it("allows request-time data in runtime instant shells", async () => { + const common = createCommonOptions(); + const prerenderToReadableStream: NonNullable< + Parameters[0]["prerenderToReadableStream"] + > = vi.fn(() => { + expect(suspendStaticInstantPrefetchRequestData("cookies()")).toBeNull(); + return Promise.resolve({ prelude: createStream(["runtime-instant-shell"]) }); + }); + + const response = await renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + prerenderToReadableStream, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + }); + + await expect(response.text()).resolves.toBe("runtime-instant-shell"); + }); + it("returns pre-render special responses before starting the render stream", async () => { const common = createCommonOptions(); @@ -1702,6 +1925,7 @@ describe("layoutFlags injection into RSC payload", () => { probeLayoutAt?: (index: number) => unknown; classification?: LayoutClassificationOptions | null; routePattern?: string; + retainedPrefetchLayoutIds?: readonly string[]; skipDisposition?: ClientReuseManifestSkipDisposition; }) { let capturedElement: Record | null = null; @@ -1747,6 +1971,7 @@ describe("layoutFlags injection into RSC payload", () => { return createStream(["flight-data"]); }, routePattern: overrides.routePattern ?? "/test", + retainedPrefetchLayoutIds: overrides.retainedPrefetchLayoutIds, runWithSuppressedHookWarning: (probe: () => Promise) => probe(), element: overrides.element ?? { "page:/test": "test-page" }, classification: overrides.classification, @@ -1802,6 +2027,64 @@ describe("layoutFlags injection into RSC payload", () => { expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "d" }); }); + it("omits retained dynamic layouts and marks the personalized response no-store", async () => { + const sharedLayoutId = "layout:/shared"; + const { options, getCapturedElement } = createRscOptions({ + element: { + [APP_ROOT_LAYOUT_KEY]: "/", + "layout:/": "root-layout", + [sharedLayoutId]: "shared-layout", + "page:/shared/two": "page-two", + }, + layoutCount: 2, + probeLayoutAt: () => null, + retainedPrefetchLayoutIds: [sharedLayoutId], + classification: { + getLayoutId: (index) => ["layout:/", sharedLayoutId][index], + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: true }; + }, + }, + }); + + const response = await renderAppPageLifecycle(options); + await response.text(); + + expect(Object.hasOwn(getCapturedElement(), sharedLayoutId)).toBe(false); + expect(getCapturedElement()[AppElementsWire.keys.skippedLayoutIds]).toEqual([sharedLayoutId]); + expect(getCapturedElement()[AppElementsWire.keys.retainedOnlySkippedLayoutIds]).toEqual([ + sharedLayoutId, + ]); + expect(response.headers.get(VINEXT_RSC_LAYOUT_IDS_HEADER)).toBe("layout:/"); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + }); + + it("does not require a retained provider when standard reuse also authorizes the omission", async () => { + const sharedLayoutId = "layout:/shared"; + const { options, getCapturedElement } = createRscOptions({ + element: { + [APP_ROOT_LAYOUT_KEY]: "/", + "layout:/": "root-layout", + [sharedLayoutId]: "shared-layout", + "page:/shared/two": "page-two", + }, + retainedPrefetchLayoutIds: [sharedLayoutId], + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: [sharedLayoutId], + }, + }); + + await renderAppPageLifecycle(options); + + expect(getCapturedElement()[AppElementsWire.keys.skippedLayoutIds]).toEqual([sharedLayoutId]); + expect(getCapturedElement()[AppElementsWire.keys.retainedOnlySkippedLayoutIds]).toBeUndefined(); + }); + it("injects empty __layoutFlags when classification is not provided (backward compat)", async () => { const { options, getCapturedElement } = createRscOptions({ element: { "layout:/": "root-layout", "page:/test": "test-page" }, diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 7744e08260..6e2efd48fe 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -28,6 +28,7 @@ import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server import { APP_RSC_RENDER_MODE_PREFETCH_EMPTY, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; import { @@ -682,6 +683,44 @@ describe("app page route wiring helpers", () => { ]); }); + it("marks client layout params as transport data in static instant shells", () => { + // Ported from Next.js Cache Components behavior: + // packages/next/src/server/app-render/create-component-tree.tsx + const ClientLayout = Object.assign(({ children }: { children?: ReactNode }) => children, { + $$typeof: Symbol.for("react.client.reference"), + }); + const suspendStaticInstantShellOptions: Array = []; + + buildAppPageElements({ + element: createElement(PageProbe), + makeThenableParams(params, _observer, options) { + suspendStaticInstantShellOptions.push(options?.suspendStaticInstantShell); + return Promise.resolve(params); + }, + matchedParams: { slug: "hello" }, + renderMode: APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [1], + layouts: [{ default: ClientLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["[slug]"], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/hello", + rootNotFoundModule: null, + }); + + expect(suspendStaticInstantShellOptions).toEqual([false]); + }); + it("encodes the active app source page from route segments", () => { // Ported from Next.js: test/e2e/app-dir/app/index.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts @@ -995,6 +1034,148 @@ describe("app page route wiring helpers", () => { expect(html.indexOf('data-slot-layout="nested"')).toBeLessThan(html.indexOf("data-slot-page")); }); + it("stages a static parallel-slot layout separately from its runtime page", async () => { + function StaticSlotLayout(props: Record) { + return createElement("section", null, readChildren(props.children)); + } + function RuntimeSlotPage() { + return createElement("p", null, "runtime slot page"); + } + const staticLayoutModule: AppPageModule = { default: StaticSlotLayout }; + const runtimePageModule: AppPageModule = { + default: RuntimeSlotPage, + unstable_instant: { prefetch: "runtime" }, + }; + const elements = buildAppPageElements({ + element: createElement(PageProbe), + instantPrefetchSegments: { + layoutStages: ["static"], + resolveStage(module, inheritedStage) { + return module?.unstable_instant && + typeof module.unstable_instant === "object" && + Reflect.get(module.unstable_instant, "prefetch") === "runtime" + ? "runtime" + : inheritedStage; + }, + wrap(element, stage) { + return createElement("div", { "data-instant-stage": stage }, element); + }, + }, + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["dashboard"], + slots: { + sidebar: { + configLayouts: [staticLayoutModule], + default: null, + error: null, + layout: null, + layoutIndex: 0, + loading: null, + name: "sidebar", + page: runtimePageModule, + routeSegments: ["members"], + }, + }, + templateTreePositions: [], + templates: [], + }, + routePath: "/dashboard", + rootNotFoundModule: null, + }); + + const html = await renderRouteEntry(elements, "route:/dashboard"); + expect(html).toContain('
'); + expect(html).toContain('

runtime slot page

'); + }); + + it("applies segment-local stages to intercepted slot overrides", async () => { + function StaticInterceptLayout(props: Record) { + return createElement("section", null, readChildren(props.children)); + } + function RuntimeInterceptPage() { + return createElement("p", null, "runtime intercept page"); + } + const staticInterceptLayoutModule: AppPageModule = { default: StaticInterceptLayout }; + const runtimeInterceptPageModule: AppPageModule = { + default: RuntimeInterceptPage, + unstable_instant: { prefetch: "runtime" }, + }; + const elements = buildAppPageElements({ + element: createElement(PageProbe), + instantPrefetchSegments: { + layoutStages: ["static"], + resolveStage(module, inheritedStage) { + return module?.unstable_instant && + typeof module.unstable_instant === "object" && + Reflect.get(module.unstable_instant, "prefetch") === "runtime" + ? "runtime" + : inheritedStage; + }, + wrap(element, stage) { + return createElement("div", { "data-instant-stage": stage }, element); + }, + }, + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["photos"], + slots: { + sidebar: { + default: null, + error: null, + layout: null, + layoutIndex: 0, + loading: null, + name: "sidebar", + page: null, + routeSegments: ["photo"], + }, + }, + templateTreePositions: [], + templates: [], + }, + routePath: "/photos", + rootNotFoundModule: null, + slotOverrides: { + sidebar: { + branchSegments: ["photo"], + layoutModules: [staticInterceptLayoutModule], + layoutSegments: [["photo"]], + pageModule: runtimeInterceptPageModule, + routeSegments: ["photo"], + }, + }, + }); + + const html = await renderRouteEntry(elements, "route:/photos"); + expect(html).toContain('
'); + expect(html).toContain('

runtime intercept page

'); + }); + it("keeps route loading boundaries in the rendered tree", () => { const elements = buildAppPageElements({ element: createElement(PageProbe), diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 48991d2cb9..112340c6c6 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vite-plus/test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import nodeFs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; @@ -120,6 +121,261 @@ async function createSemanticIdsFixture(appDir: string): Promise { } describe("App Router route graph builder", () => { + it("propagates runtime unstable_instant from pages, layouts, and active slots", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile( + appDir, + "page-instant/page.tsx", + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "layout-instant/layout.tsx", + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_LAYOUT}`, + ); + await writeAppFile(appDir, "layout-instant/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "slot-instant/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "slot-instant/default.tsx", EMPTY_PAGE); + await writeAppFile( + appDir, + "slot-instant/@team/page.tsx", + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "static-instant/page.tsx", + `export const unstable_instant = { prefetch: "static" };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "false-instant/page.tsx", + `export const unstable_instant = false;\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "client-instant/page.tsx", + `"use client";\nexport const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "reexported-instant/config.ts", + 'export const config = { prefetch: "runtime", samples: [{}] };\n', + ); + await writeAppFile( + appDir, + "reexported-instant/page.tsx", + `export { config as unstable_instant } from "./config";\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "indirect-instant/page.tsx", + `const base = { prefetch: "runtime", samples: [{}] }; const config = base; export { config as unstable_instant };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "aliased-reexport-instant/page.tsx", + `export { unstable_instant } from "@/instant-config";\n${EMPTY_PAGE}`, + ); + await writeAppFile(appDir, "plain/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + + expect(findRoute(graph.routes, "/page-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/layout-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/slot-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/reexported-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/indirect-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/aliased-reexport-instant").hasRuntimeInstant).toBe(true); + expect(findRoute(graph.routes, "/static-instant").hasInstant).toBe(true); + expect(findRoute(graph.routes, "/static-instant").hasRuntimeInstant).toBe(false); + expect(findRoute(graph.routes, "/false-instant").hasInstantConfig).toBe(true); + expect(findRoute(graph.routes, "/false-instant").hasInstant).toBe(false); + expect(findRoute(graph.routes, "/client-instant").hasInstantConfigInClientModule).toBe(true); + expect(findRoute(graph.routes, "/plain").hasInstant).toBe(false); + expect(findRoute(graph.routes, "/plain").hasRuntimeInstant).toBe(false); + }); + }); + + it("validates the complete unstable_instant schema", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + + for (const config of [ + "true", + "null", + "undefined", + "{}", + '{ prefetch: "other" }', + '{ prefetch: "runtime" }', + '{ prefetch: "runtime", samples: [] }', + '{ prefetch: "static", samples: [] }', + '{ prefetch: "static", extra: true }', + '{ prefetch: "static", samples: [{ extra: true }] }', + '{ prefetch: "runtime", samples: [{ cookies: [{ name: "id" }] }] }', + '{ prefetch: "runtime", samples: [{ cookies: [{ name: "id", value: null, extra: true }] }] }', + '{ prefetch: "runtime", samples: [{ headers: [["x-only-one"]] }] }', + '{ prefetch: "runtime", samples: [{ headers: [["x", false]] }] }', + '{ prefetch: "runtime", samples: [{ params: { id: 1 } }] }', + '{ prefetch: "runtime", samples: [{ searchParams: { q: false } }] }', + '{ prefetch: "static", from: [1] }', + '{ prefetch: "static", unstable_disableValidation: false }', + ]) { + await writeAppFile( + appDir, + "page.tsx", + `export const unstable_instant = ${config};\n${EMPTY_PAGE}`, + ); + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow( + /Invalid unstable_instant value/, + ); + } + + await writeAppFile( + appDir, + "instant-config.ts", + 'export const config = { prefetch: "runtime", samples: [] };\n', + ); + await writeAppFile( + appDir, + "page.tsx", + `export { config as unstable_instant } from "./instant-config";\n${EMPTY_PAGE}`, + ); + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow( + /Invalid unstable_instant value/, + ); + + await writeAppFile( + appDir, + "page.tsx", + `const prefetch = process.env.INSTANT_MODE; + export const unstable_instant = { prefetch }; + ${EMPTY_PAGE}`, + ); + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow( + /must be statically analyzable/, + ); + + await writeAppFile( + appDir, + "page.tsx", + `const config = { prefetch: "runtime", samples: [config] }; + export { config as unstable_instant }; + ${EMPTY_PAGE}`, + ); + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow( + /must be statically analyzable/, + ); + + for (const config of [ + "false", + '{ prefetch: "static" }', + '{ prefetch: "static", samples: [{}] }', + '{ prefetch: "runtime", samples: [{}] }', + `{ prefetch: "runtime", samples: [{ + cookies: [{ name: "id", value: null }], + headers: [["x-test", "value"]], + params: { slug: ["one", "two"] }, + searchParams: { q: null } + }], from: [], unstable_disableValidation: true, + unstable_disableDevValidation: true, unstable_disableBuildValidation: true }`, + ]) { + await writeAppFile( + appDir, + "page.tsx", + `export const unstable_instant = ${config};\n${EMPTY_PAGE}`, + ); + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).resolves.toBeDefined(); + } + }); + }); + + it("rejects unstable_instant with unstable_dynamicStaleTime in one segment", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile( + appDir, + "page.tsx", + `export const unstable_instant = { prefetch: "static" }; + export const unstable_dynamicStaleTime = 30; + ${EMPTY_PAGE}`, + ); + + await expect(buildAppRouteGraph(appDir, createValidFileMatcher())).rejects.toThrow( + /cannot use both `export const unstable_dynamicStaleTime` and `export const unstable_instant`/, + ); + }); + }); + + it("replaces runtime instant config dependencies on each graph rebuild", async () => { + await withTempApp(async (appDir) => { + const configPath = path.join(appDir, "instant-config.ts"); + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile( + appDir, + "page.tsx", + `export { unstable_instant } from "./instant-config";\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "instant-config.ts", + 'export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n', + ); + + let graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect(graph.instantConfigDependencies.has(canonical(appDir, "page.tsx"))).toBe(true); + expect(graph.instantConfigDependencies.has(canonical(configPath))).toBe(true); + + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect(graph.instantConfigDependencies.has(canonical(appDir, "page.tsx"))).toBe(false); + expect(graph.instantConfigDependencies.has(canonical(configPath))).toBe(false); + }); + }); + + it("tracks missing relative instant config targets for watcher add events", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile( + appDir, + "page.tsx", + `export { unstable_instant } from "./missing-config";\n${EMPTY_PAGE}`, + ); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect(graph.instantConfigDependencies.has(canonical(appDir, "missing-config.ts"))).toBe( + true, + ); + expect(findRoute(graph.routes, "/").hasRuntimeInstant).toBe(true); + }); + }); + + it("analyzes shared instant layouts once per graph build", async () => { + await withTempApp(async (appDir) => { + const layoutPath = canonical(appDir, "layout.tsx"); + await writeAppFile( + appDir, + "layout.tsx", + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_LAYOUT}`, + ); + await writeAppFile(appDir, "one/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "two/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "three/page.tsx", EMPTY_PAGE); + const readFileSync = vi.spyOn(nodeFs, "readFileSync"); + + try { + await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect( + readFileSync.mock.calls.filter( + ([filePath]) => canonical(String(filePath)) === layoutPath, + ), + ).toHaveLength(1); + } finally { + readFileSync.mockRestore(); + } + }); + }); + it("materializes pages, handlers, layouts, and inherited parallel slots", async () => { await withTempApp(async (appDir) => { await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); diff --git a/tests/app-rsc-cache-busting.test.ts b/tests/app-rsc-cache-busting.test.ts index 71044b3dd3..17a1739cb5 100644 --- a/tests/app-rsc-cache-busting.test.ts +++ b/tests/app-rsc-cache-busting.test.ts @@ -16,7 +16,10 @@ import { VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; -import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_CLIENT_REUSE_MANIFEST_HEADER, + VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, +} from "../packages/vinext/src/server/headers.js"; import { fnv1a64 } from "../packages/vinext/src/utils/hash.js"; import { withEnvVar } from "./env-test-helpers.js"; @@ -107,6 +110,19 @@ describe("App Router RSC cache-busting", () => { ); }); + it("varies RSC requests by retained prefetch layouts", async () => { + const withoutRetained = createRscRequestHeaders(); + const withRetained = createRscRequestHeaders({ + retainedPrefetchLayoutsHeader: "layout:/shared", + }); + + expect(withRetained.get(VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER)).toBe("layout:/shared"); + await expect(computeRscCacheBustingSearchParam(withRetained)).resolves.not.toBe( + await computeRscCacheBustingSearchParam(withoutRetained), + ); + expect(VINEXT_RSC_VARY_HEADER).toContain(VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER); + }); + it("keeps router state but omits the prefetch header for a full prefetch", () => { const headers = createRscRequestHeaders({ nextUrl: "/current", @@ -313,7 +329,7 @@ describe("App Router RSC cache-busting", () => { // Mirrors Next.js App Router's base Vary header: // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/route-modules/app-page/module.ts expect(VINEXT_RSC_VARY_HEADER).toBe( - "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url, X-Vinext-Interception-Context, X-Vinext-Mounted-Slots, X-Vinext-Rsc-Render-Mode", + "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Next-Url, X-Vinext-Interception-Context, X-Vinext-Mounted-Slots, X-Vinext-Retained-Prefetch-Layouts, X-Vinext-Rsc-Render-Mode", ); expect(VINEXT_RSC_VARY_HEADER.split(", ")).not.toContain("Accept"); }); diff --git a/tests/app-rsc-request-normalization.test.ts b/tests/app-rsc-request-normalization.test.ts index 2e4838862b..0e7d07cb87 100644 --- a/tests/app-rsc-request-normalization.test.ts +++ b/tests/app-rsc-request-normalization.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from "vite-plus/test"; import { normalizeRscRequest, normalizeMountedSlotsHeader, + parseRetainedPrefetchLayoutIds, type NormalizedRscRequest, } from "../packages/vinext/src/server/app-rsc-request-normalization.js"; import { @@ -38,8 +39,35 @@ import { NEXT_URL_HEADER, RSC_HEADER, VINEXT_CLIENT_REUSE_MANIFEST_HEADER, + VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER, } from "../packages/vinext/src/server/headers.js"; +describe("normalizeRscRequest — retained prefetch layouts", () => { + it("accepts only bounded, unique layout IDs from RSC requests", () => { + const ids = Array.from({ length: 70 }, (_, index) => `layout:/section-${index}`); + const parsed = parseRetainedPrefetchLayoutIds( + ["page:/not-a-layout", "layout:/shared", "layout:/shared", ...ids].join(" "), + ); + + expect(parsed).toHaveLength(64); + expect(parsed.slice(0, 3)).toEqual([ + "layout:/shared", + "layout:/section-0", + "layout:/section-1", + ]); + }); + + it("ignores retained layout hints on non-RSC requests", () => { + const result = normalized( + normalizeRscRequest( + req("/dashboard", { [VINEXT_RETAINED_PREFETCH_LAYOUTS_HEADER]: "layout:/shared" }), + "", + ), + ); + expect(result.retainedPrefetchLayoutIds).toEqual([]); + }); +}); + function prefetchRouterStateHeader(pathAndSearch: string, routeId = "route:/dashboard"): string { return encodeURIComponent(JSON.stringify({ pathAndSearch, routeId })); } diff --git a/tests/app-rsc-response-finalizer.test.ts b/tests/app-rsc-response-finalizer.test.ts index ad2a77687a..212da53b8b 100644 --- a/tests/app-rsc-response-finalizer.test.ts +++ b/tests/app-rsc-response-finalizer.test.ts @@ -4,6 +4,7 @@ import { finalizeAppRscResponse, markAppRscResponseConfigHeadersApplied, } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; +import { VINEXT_RSC_PARTIAL_SHELL_HEADER } from "../packages/vinext/src/server/headers.js"; import type { RequestContext } from "../packages/vinext/src/config/request-context.js"; function makeRequestContext(headers: Headers = new Headers()): RequestContext { @@ -54,6 +55,39 @@ describe("finalizeAppRscResponse — config header application", () => { expect(response.headers.get("x-route-value")).toBe("target"); }); + it("locks partial instant shells to no-store after middleware and config headers", async () => { + const response = new Response("partial flight", { + headers: { + [VINEXT_RSC_PARTIAL_SHELL_HEADER]: "1", + "cache-control": "public, max-age=3600", + "cdn-cache-control": "max-age=3600", + "cloudflare-cdn-cache-control": "max-age=3600", + "cache-tag": "unsafe-partial-shell", + }, + }); + + await finalizeAppRscResponse(response, new Request("http://example.com/instant"), { + basePath: "", + configHeaders: [ + { + source: "/instant", + headers: [ + { key: "cache-control", value: "public, s-maxage=86400" }, + { key: "cdn-cache-control", value: "max-age=86400" }, + ], + }, + ], + i18nConfig: null, + requestContext: makeRequestContext(), + }); + + expect(response.headers.get(VINEXT_RSC_PARTIAL_SHELL_HEADER)).toBe("1"); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(response.headers.get("cdn-cache-control")).toBeNull(); + expect(response.headers.get("cloudflare-cdn-cache-control")).toBeNull(); + expect(response.headers.get("cache-tag")).toBeNull(); + }); + it("adds the App Router RSC vary header when no config headers are configured", async () => { // Behavior: App Router responses always carry the RSC vary key, even when // no next.config.js headers match. This covers app route handlers that diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 7ac4be4ffb..5113758d19 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -10,8 +10,11 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; import { + analyzeNamedExportObjectStringProperty, + findNamedExternalReexport, hasExportedName, hasNamedExport, + hasNamedExportObjectStringProperty, extractExportConstString, extractExportConstNumber, extractGetStaticPropsRevalidate, @@ -108,6 +111,137 @@ export function getServerSideProps() {} }); }); +describe("hasNamedExportObjectStringProperty", () => { + it("recognizes direct and locally aliased runtime instant configs", () => { + expect( + hasNamedExportObjectStringProperty( + "export const unstable_instant = { prefetch: 'runtime', samples: [] };", + "unstable_instant", + "prefetch", + "runtime", + ), + ).toBe(true); + expect( + hasNamedExportObjectStringProperty( + "const config = { prefetch: 'runtime' }; export { config as unstable_instant };", + "unstable_instant", + "prefetch", + "runtime", + ), + ).toBe(true); + expect( + hasNamedExportObjectStringProperty( + "const base = { prefetch: 'runtime' }; const config = base; export { config as unstable_instant };", + "unstable_instant", + "prefetch", + "runtime", + ), + ).toBe(true); + expect( + hasNamedExportObjectStringProperty( + "const mode = 'runtime'; export const unstable_instant = { prefetch: mode };", + "unstable_instant", + "prefetch", + "runtime", + ), + ).toBe(true); + }); + + it("does not classify static, false, or external runtime values", () => { + for (const source of [ + "export const unstable_instant = { prefetch: 'static' };", + "export const unstable_instant = false;", + "export { config as unstable_instant } from './config';", + ]) { + expect( + hasNamedExportObjectStringProperty(source, "unstable_instant", "prefetch", "runtime"), + ).toBe(false); + } + }); + + it("describes external named re-exports without executing their modules", () => { + expect( + findNamedExternalReexport( + "export { config as unstable_instant } from './config';", + "unstable_instant", + ), + ).toEqual({ importedName: "config", source: "./config" }); + expect( + findNamedExternalReexport( + "export { unstable_instant } from './config.js';", + "unstable_instant", + ), + ).toEqual({ importedName: "unstable_instant", source: "./config.js" }); + }); +}); + +describe("analyzeNamedExportObjectStringProperty", () => { + it("reports export presence, client directives, values, and re-exports in one analysis", () => { + expect( + analyzeNamedExportObjectStringProperty( + '"use client"; export const unstable_instant = false;', + "unstable_instant", + "prefetch", + ), + ).toMatchObject({ + hasExport: true, + hasUseClientDirective: true, + hasStaticValue: true, + staticValue: false, + propertyValue: null, + reexport: null, + }); + expect( + analyzeNamedExportObjectStringProperty( + 'export { config as unstable_instant } from "./config";', + "unstable_instant", + "prefetch", + ), + ).toMatchObject({ + hasExport: true, + hasStaticValue: false, + propertyValue: null, + reexport: { importedName: "config", source: "./config" }, + }); + }); + + it("extracts complete static object values through local const aliases", () => { + expect( + analyzeNamedExportObjectStringProperty( + `const mode = "runtime"; + const samples = [{ params: { slug: ["one", "two"] } }]; + const config = { prefetch: mode, samples }; + export { config as unstable_instant };`, + "unstable_instant", + "prefetch", + ), + ).toMatchObject({ + hasExport: true, + hasStaticValue: true, + staticValue: { + prefetch: "runtime", + samples: [{ params: { slug: ["one", "two"] } }], + }, + propertyValue: "runtime", + }); + }); + + it("rejects cyclic local const values without overflowing", () => { + expect( + analyzeNamedExportObjectStringProperty( + `const config = { prefetch: "runtime", samples: [config] }; + export { config as unstable_instant };`, + "unstable_instant", + "prefetch", + ), + ).toMatchObject({ + hasExport: true, + hasStaticValue: false, + propertyValue: "runtime", + }); + }); +}); + // ─── extractExportConstString ───────────────────────────────────────────────── describe("extractExportConstString", () => { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index d111da1590..30a631f33d 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -414,6 +414,52 @@ describe("App Router generated manifest construction", () => { ).toBe(false); }); + it("advertises runtime instant routes to the browser prefetch policy", () => { + expect( + toLinkPrefetchRoute({ + ...minimalAppRoutes[1], + hasInstant: true, + hasRuntimeInstant: true, + }), + ).toMatchObject({ + canPrefetchLoadingShell: false, + hasInstant: true, + hasRuntimeInstant: true, + patternParts: ["about"], + }); + }); + + it("rejects unstable_instant routes when Cache Components are disabled", () => { + expect(() => + toLinkPrefetchRoutes( + [ + { + ...minimalAppRoutes[1], + hasInstant: false, + hasInstantConfig: true, + hasRuntimeInstant: false, + }, + ], + false, + ), + ).toThrow(/without enabling `cacheComponents`/); + }); + + it("rejects unstable_instant exports from Client Component modules", () => { + expect(() => + toLinkPrefetchRoutes( + [ + { + ...minimalAppRoutes[1], + hasInstantConfig: true, + hasInstantConfigInClientModule: true, + }, + ], + true, + ), + ).toThrow(/cannot export "unstable_instant" from a Client Component/); + }); + it("does not advertise an already-shared root loading boundary for nested static routes", () => { const route = { ...minimalAppRoutes[0], diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index a8c15c395f..ac949dd28b 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -10,9 +10,12 @@ import { } from "../packages/vinext/src/shims/link-prefetch.js"; import { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { + NEXT_ROUTER_STALE_TIME_HEADER, NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, @@ -62,6 +65,19 @@ const linkPrefetchRoutes = [ { canPrefetchLoadingShell: true, patternParts: ["blog", ":slug"], isDynamic: true }, { canPrefetchLoadingShell: false, patternParts: ["products", ":id"], isDynamic: true }, { canPrefetchLoadingShell: false, patternParts: ["clothing", ":product"], isDynamic: true }, + { + canPrefetchLoadingShell: false, + hasInstant: true, + hasRuntimeInstant: true, + patternParts: ["instant-target"], + isDynamic: false, + }, + { + canPrefetchLoadingShell: false, + hasInstant: true, + patternParts: ["static-instant-target"], + isDynamic: false, + }, { canPrefetchLoadingShell: false, patternParts: ["teams", ":team", "dashboard"], @@ -2102,6 +2118,43 @@ describe("Link prefetch scheduling", () => { } }); + it("does not replace a fresh completed navigation with a learning-only Link prefetch", async () => { + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/blog/hello", + nodeEnv: "production", + }); + const { createRscRequestHeaders, createRscRequestUrl } = + await import("../packages/vinext/src/server/app-rsc-cache-busting.js"); + const { getPrefetchCache, getPrefetchedUrls } = + await import("../packages/vinext/src/shims/navigation.js"); + const rscUrl = await createRscRequestUrl("/blog/hello", createRscRequestHeaders()); + + try { + getPrefetchCache().set(rscUrl, { + cacheForNavigation: true, + outcome: "cache-seeded", + snapshot: { + buffer: new TextEncoder().encode("completed navigation").buffer, + contentType: "text/x-component", + mountedSlotsHeader: null, + paramsHeader: null, + renderedPathAndSearch: null, + url: rscUrl, + }, + timestamp: Date.now(), + }); + getPrefetchedUrls().add(rscUrl); + + observer.dispatchIntersectingEntry(result.anchor); + await flushPrefetchTasks(); + + expect(result.fetch).not.toHaveBeenCalled(); + } finally { + result.restoreNodeEnv(); + } + }); + it("uses the static stale time for reusable automatic dynamic full prefetches", async () => { vi.stubEnv("__NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME", "0"); vi.stubEnv("__NEXT_CLIENT_ROUTER_STATIC_STALETIME", "300"); @@ -2278,6 +2331,110 @@ describe("Link prefetch scheduling", () => { } }); + it("promotes complete runtime instant shells from explicit full prefetches", async () => { + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/instant-target", + nodeEnv: "production", + props: { prefetch: true }, + }); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined; + const headers = fetchInit?.headers as Headers | undefined; + expect(headers?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBeNull(); + expect(headers?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe( + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, + ); + const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); + const entry = [...getPrefetchCache().values()][0]; + await entry?.pending; + expect(entry).toMatchObject({ + cacheForNavigation: true, + instantShell: true, + prefetchKind: "instant-shell", + retainedLayoutProvider: true, + }); + } finally { + result.restoreNodeEnv(); + } + }); + + it("re-prefetches a promoted runtime instant shell after its cacheLife expires", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_000_000); + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/instant-target", + nodeEnv: "production", + }); + result.fetch.mockResolvedValueOnce( + new Response("", { + headers: { + [NEXT_ROUTER_STALE_TIME_HEADER]: "120", + [VINEXT_DYNAMIC_STALE_TIME_HEADER]: "30", + }, + }), + ); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); + const entry = [...getPrefetchCache().values()][0]; + await entry?.pending; + expect(entry).toMatchObject({ + cacheForNavigation: true, + expiresAt: 1_120_000, + instantShell: true, + }); + + vi.spyOn(Date, "now").mockReturnValue(1_119_999); + pingVisibleLinksFromRuntime(); + await flushPrefetchTasks(); + expect(result.fetch).toHaveBeenCalledTimes(1); + + vi.spyOn(Date, "now").mockReturnValue(1_120_001); + pingVisibleLinksFromRuntime(); + await waitForFetchCalls(result.fetch, 2); + expect(result.fetch).toHaveBeenCalledTimes(2); + } finally { + result.restoreNodeEnv(); + } + }); + + it("renders cache-aware instant shells for static instant prefetches", async () => { + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/static-instant-target", + nodeEnv: "production", + props: { prefetch: true }, + }); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + + const fetchInit = result.fetch.mock.calls[0]?.[1] as RequestInit | undefined; + const headers = fetchInit?.headers as Headers | undefined; + expect(headers?.get(NEXT_ROUTER_PREFETCH_HEADER)).toBeNull(); + expect(headers?.get(VINEXT_RSC_RENDER_MODE_HEADER)).toBe( + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, + ); + const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); + expect([...getPrefetchCache().values()][0]).toMatchObject({ + cacheForNavigation: false, + instantShell: true, + prefetchKind: "instant-shell", + }); + } finally { + result.restoreNodeEnv(); + } + }); + it("refetches instead of promoting an exact learning-only entry for a full Link prefetch", async () => { const observer = stubIntersectionObserver(); const result = await renderIsolatedLink({ diff --git a/tests/link.test.ts b/tests/link.test.ts index 92f1b4fc47..f92af812d4 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -36,6 +36,7 @@ import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/ro import { runWithI18nState } from "../packages/vinext/src/shims/i18n-state.js"; import { setI18nContext } from "../packages/vinext/src/shims/i18n-context.js"; import { addLocalePrefix } from "../packages/vinext/src/utils/domain-locale.js"; +import { resolveFullAppRoutePrefetch } from "../packages/vinext/src/shims/internal/app-route-prefetch-policy.js"; import { isAbsoluteOrProtocolRelativeUrl, @@ -457,6 +458,64 @@ describe("Link App Router prefetch mode", () => { } }); + it("selects an optimistic instant shell for runtime unstable_instant routes", () => { + const originalWindow = globalThis.window; + (globalThis as any).window = { + location: { href: "http://localhost/", origin: "http://localhost" }, + __VINEXT_LINK_PREFETCH_ROUTES__: [ + { + canPrefetchLoadingShell: false, + hasRuntimeInstant: true, + isDynamic: false, + patternParts: ["instant"], + }, + ], + }; + + try { + expect(resolveAutoAppRoutePrefetch("/instant")).toEqual({ + cacheForNavigation: false, + fallbackTtl: "dynamic", + honorDynamicStaleTime: true, + prefetchInstantShell: "runtime", + prefetchShellFirst: false, + shouldPrefetch: true, + }); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + } + }); + + it("downgrades full prefetches for static unstable_instant routes", () => { + const originalWindow = globalThis.window; + (globalThis as any).window = { + location: { href: "http://localhost/", origin: "http://localhost" }, + __VINEXT_LINK_PREFETCH_ROUTES__: [ + { + canPrefetchLoadingShell: false, + hasInstant: true, + isDynamic: false, + patternParts: ["instant-static"], + }, + ], + }; + + try { + expect(resolveFullAppRoutePrefetch("/instant-static")).toEqual({ + cacheForNavigation: false, + fallbackTtl: "static", + honorDynamicStaleTime: true, + prefetchInstantShell: "static", + prefetchShellFirst: false, + shouldPrefetch: true, + }); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + } + }); + it("keeps Cache Components encoded delimiters and fully dynamic roots learning-only", () => { const originalWindow = globalThis.window; const originalCacheComponents = process.env.__NEXT_CACHE_COMPONENTS; diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index eb352c463e..5bf9e89787 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3700,6 +3700,131 @@ describe("Virtual server entry generation", () => { } }); + it("refreshes runtime instant route metadata after external config changes", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-instant-hmr-")); + const appDir = path.join(tmpDir, "app"); + const pagePath = path.join(appDir, "page.tsx"); + const configPath = path.join(tmpDir, "instant-config.ts"); + fs.mkdirSync(appDir, { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "pages"), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "pages", "index.tsx"), + "export default function Page() { return null; }\n", + ); + fs.symlinkSync(path.join(process.cwd(), "node_modules"), path.join(tmpDir, "node_modules")); + fs.writeFileSync( + path.join(appDir, "layout.tsx"), + "export default function Layout({ children }) { return {children}; }\n", + ); + fs.writeFileSync( + pagePath, + 'export { unstable_instant } from "../instant-config";\n' + + "export default function Page() { return null; }\n", + ); + fs.writeFileSync(configPath, 'export const unstable_instant = { prefetch: "static" };\n'); + + const testServer = await createServer({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir, nextConfig: { cacheComponents: true } })], + server: { port: 0 }, + logLevel: "silent", + }); + + try { + const resolved = await testServer.pluginContainer.resolveId( + "virtual:vinext-app-browser-entry", + ); + expect(resolved).toBeTruthy(); + const pagesClientResolved = await testServer.pluginContainer.resolveId( + "virtual:vinext-client-entry", + ); + expect(pagesClientResolved).toBeTruthy(); + const pagesClientModule = testServer.environments.client.moduleGraph.createFileOnlyEntry( + pagesClientResolved!.id, + ); + const clientModuleGraph = testServer.environments.client.moduleGraph; + const originalGetModuleById = clientModuleGraph.getModuleById.bind(clientModuleGraph); + const getClientModuleById = vi + .spyOn(clientModuleGraph, "getModuleById") + .mockImplementation((id) => + id === pagesClientResolved!.id ? pagesClientModule : originalGetModuleById(id), + ); + const invalidateClientModule = vi.spyOn(clientModuleGraph, "invalidateModule"); + const rscHotSend = vi.spyOn(testServer.environments.rsc.hot, "send"); + const loadCode = async () => { + const loaded = await testServer.pluginContainer.load(resolved!.id); + return typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? ""); + }; + expect(await loadCode()).not.toContain('"hasRuntimeInstant":true'); + + fs.writeFileSync( + configPath, + 'export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n' + + 'export const marker = "updated";\n', + ); + testServer.watcher.emit("change", configPath); + + await vi.waitFor(async () => { + expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + }); + expect(getClientModuleById).toHaveBeenCalledWith(pagesClientResolved!.id); + expect(invalidateClientModule).toHaveBeenCalledWith(pagesClientModule); + + fs.rmSync(configPath); + testServer.watcher.emit("unlink", configPath); + // Missing relative re-exports are conservatively runtime instant. + await vi.waitFor(async () => { + expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + }); + + fs.writeFileSync(configPath, 'export const unstable_instant = { prefetch: "static" };\n'); + testServer.watcher.emit("add", configPath); + await vi.waitFor(async () => { + expect(await loadCode()).not.toContain('"hasRuntimeInstant":true'); + }); + + // Once the graph no longer tracks this page as an instant-config + // dependency, adding a locally aliased export must still invalidate the + // browser entry. The exported name, not the local binding name, is the + // route-config contract. + fs.writeFileSync(pagePath, "export default function Page() { return null; }\n"); + testServer.watcher.emit("change", pagePath); + await vi.waitFor(async () => { + expect(await loadCode()).not.toContain('"hasInstant":true'); + }); + + fs.writeFileSync( + pagePath, + 'const config = { prefetch: "runtime", samples: [{}] };\n' + + "export { config as unstable_instant };\n" + + "export default function Page() { return null; }\n", + ); + testServer.watcher.emit("change", pagePath); + await vi.waitFor(async () => { + expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + }); + + const countFullReloads = () => + rscHotSend.mock.calls.filter( + ([event]) => (event as unknown as { type?: string }).type === "full-reload", + ).length; + const fullReloadCount = countFullReloads(); + fs.writeFileSync( + pagePath, + 'const config = { prefetch: "runtime", samples: [{}] };\n' + + "export { config as unstable_instant };\n" + + "export default function Page() { return

ordinary edit

; }\n", + ); + testServer.watcher.emit("change", pagePath); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(countFullReloads()).toBe(fullReloadCount); + } finally { + await testServer.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("client entry uses Next.js bracket format for dynamic route keys", async () => { // The client entry generates a pageLoaders map keyed by route pattern. // These keys MUST match __NEXT_DATA__.page (which uses Next.js bracket diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index b330babf69..c55991024b 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -24,6 +24,8 @@ import { VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, + VINEXT_RSC_LAYOUT_IDS_HEADER, } from "../packages/vinext/src/server/headers.js"; import { appendRscCompletionMetadata } from "../packages/vinext/src/server/rsc-completion-metadata.js"; import type { PrefetchCacheEntry } from "../packages/vinext/src/shims/navigation.js"; @@ -48,6 +50,8 @@ let peekPrefetchResponseForNavigation: Navigation["peekPrefetchResponseForNaviga let appRouterInstance: Navigation["appRouterInstance"]; let consumePrefetchResponseForNavigation: Navigation["consumePrefetchResponseForNavigation"]; let seedPrefetchResponseSnapshot: Navigation["seedPrefetchResponseSnapshot"]; +let getRetainedPrefetchLayoutClaims: Navigation["getRetainedPrefetchLayoutClaims"]; +let getLiveRetainedPrefetchLayoutClaims: Navigation["getLiveRetainedPrefetchLayoutClaims"]; let createAppPrefetchRequestHeaders: Navigation["createAppPrefetchRequestHeaders"]; beforeEach(async () => { @@ -88,6 +92,8 @@ beforeEach(async () => { appRouterInstance = nav.appRouterInstance; consumePrefetchResponseForNavigation = nav.consumePrefetchResponseForNavigation; seedPrefetchResponseSnapshot = nav.seedPrefetchResponseSnapshot; + getRetainedPrefetchLayoutClaims = nav.getRetainedPrefetchLayoutClaims; + getLiveRetainedPrefetchLayoutClaims = nav.getLiveRetainedPrefetchLayoutClaims; createAppPrefetchRequestHeaders = nav.createAppPrefetchRequestHeaders; }); @@ -405,6 +411,35 @@ describe("prefetch cache eviction", () => { expect(getPrefetchCache().has(routeTreeUrl)).toBe(true); }); + it("keeps instant partial shells for optimistic learning without direct reuse", async () => { + const rscUrl = "/instant-shell.rsc"; + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("instant shell", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_PARTIAL_SHELL_HEADER]: "1", + }, + }), + ), + null, + null, + undefined, + { instantShell: true }, + ); + + await waitForPrefetchSetup(() => getPrefetchCache().get(rscUrl)?.outcome === "cache-seeded"); + + expect(getPrefetchCache().get(rscUrl)).toMatchObject({ + cacheForNavigation: false, + instantShell: true, + partialSuspenseShell: true, + prefetchKind: "instant-shell", + }); + expect(consumePrefetchResponse(rscUrl)).toBeNull(); + }); + it("derives the interception context from the current pathname", () => { (globalThis as any).window.location.pathname = "/feed"; @@ -1691,11 +1726,12 @@ describe("prefetch cache eviction", () => { expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 60_000); }); - it("uses completed cacheLife for prefetch expiry without changing the dynamic BFCache bound", async () => { + it("keeps the automatic prefetch expiry within the dynamic BFCache bound", async () => { // Ported from Next.js: // test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts - // Runtime-prefetch freshness comes from cacheLife, while staleTimes.dynamic - // independently bounds visited/BFCache reuse. + // An automatic prefetch must not outlive staleTimes.dynamic just because a + // nested cacheLife claim is longer. Full and complete runtime prefetches + // pass honorDynamicStaleTime: false and retain their longer cache window. const now = 1_000_000; vi.spyOn(Date, "now").mockReturnValue(now); const rscUrl = "/cache-life-prefetch.rsc"; @@ -1730,7 +1766,7 @@ describe("prefetch cache eviction", () => { await getPrefetchCache().get(rscUrl)?.pending; const snapshot = getPrefetchCache().get(rscUrl)?.snapshot; - expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 240_000); + expect(getPrefetchCache().get(rscUrl)?.expiresAt).toBe(now + 30_000); expect(snapshot?.serverStaleTime).toEqual({ kind: "resolved", seconds: 240 }); expect(resolveCachedRscResponseTtlMs(snapshot!, 300_000)).toBe(30_000); }); @@ -2394,6 +2430,277 @@ describe("prefetch cache eviction", () => { ); }); + it("promotes a complete runtime instant shell despite dynamic stale time zero", async () => { + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const rscUrl = "/instant-complete?_rsc=instant"; + + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_DYNAMIC_STALE_TIME_HEADER]: "0", + [VINEXT_RSC_LAYOUT_IDS_HEADER]: "layout:/runtime", + }, + }), + ), + null, + null, + undefined, + { + cacheForNavigation: false, + instantShell: true, + promoteCompleteInstantShell: true, + }, + ); + await getPrefetchCache().get(rscUrl)?.pending; + + const entry = getPrefetchCache().get(rscUrl); + expect(entry?.cacheForNavigation).toBe(true); + expect(entry?.retainedLayoutProvider).toBe(true); + expect(entry?.expiresAt).toBe(now + 30_000); + expect(consumePrefetchResponse("/instant-complete", null, null)).not.toBeNull(); + }); + + it("keeps a partial runtime instant shell learning-only", async () => { + const rscUrl = "/instant-partial?_rsc=instant"; + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("flight", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_PARTIAL_SHELL_HEADER]: "1", + }, + }), + ), + null, + null, + undefined, + { + cacheForNavigation: false, + instantShell: true, + promoteCompleteInstantShell: true, + }, + ); + await getPrefetchCache().get(rscUrl)?.pending; + + expect(getPrefetchCache().get(rscUrl)?.cacheForNavigation).toBe(false); + expect(consumePrefetchResponse("/instant-partial", null, null)).toBeNull(); + }); + + it("retains dynamic layouts when their own params match despite differing page params", () => { + (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [ + { + canPrefetchLoadingShell: false, + isDynamic: true, + patternParts: ["projects", ":projectId", ":pageId"], + }, + ]; + const cache = getPrefetchCache(); + const layoutId = "layout:/projects/[projectId]"; + const provider: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot: { + buffer: new ArrayBuffer(1), + contentType: "text/x-component", + layoutIds: [layoutId], + mountedSlotsHeader: null, + paramsHeader: encodeURIComponent(JSON.stringify({ projectId: "alpha", pageId: "one" })), + renderedPathAndSearch: null, + url: "/projects/alpha/one", + }, + timestamp: Date.now(), + }; + cache.set(AppElementsWire.encodeCacheKey("/projects/alpha/one", null), provider); + + expect( + getRetainedPrefetchLayoutClaims({ targetHref: "/projects/alpha/two" }).map( + (claim) => claim.layoutId, + ), + ).toEqual([layoutId]); + expect(getRetainedPrefetchLayoutClaims({ targetHref: "/projects/beta/two" })).toEqual([]); + }); + + it("restores retained layouts only from the exact live provider claim", () => { + const cache = getPrefetchCache(); + const layoutId = "layout:/account"; + const snapshot = { + buffer: new ArrayBuffer(1), + contentType: "text/x-component", + layoutIds: [layoutId], + mountedSlotsHeader: null, + paramsHeader: null, + renderedPathAndSearch: null, + url: "/account", + }; + const expiredClaimedProvider: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() - 1, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot, + timestamp: Date.now() - 60_000, + }; + const freshUnclaimedProvider: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot: { ...snapshot, url: "/account/fresh" }, + timestamp: Date.now(), + }; + cache.set("/account/expired", expiredClaimedProvider); + cache.set("/account/fresh", freshUnclaimedProvider); + + expect( + getLiveRetainedPrefetchLayoutClaims([{ layoutId, provider: expiredClaimedProvider }]), + ).toEqual([]); + expect( + getLiveRetainedPrefetchLayoutClaims([{ layoutId, provider: freshUnclaimedProvider }]), + ).toEqual([{ layoutId, provider: freshUnclaimedProvider }]); + }); + + it("tracks dependencies only for layouts the server actually skipped", async () => { + const cache = getPrefetchCache(); + const usedLayoutId = "layout:/shared"; + const ignoredLayoutId = "layout:/unrelated"; + const createProvider = (layoutId: string): PrefetchCacheEntry => ({ + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot: { + buffer: new ArrayBuffer(1), + contentType: "text/x-component", + layoutIds: [layoutId], + mountedSlotsHeader: null, + paramsHeader: null, + renderedPathAndSearch: null, + url: layoutId, + }, + timestamp: Date.now(), + }); + const usedProvider = createProvider(usedLayoutId); + const ignoredProvider = createProvider(ignoredLayoutId); + cache.set("/shared-provider", usedProvider); + cache.set("/unrelated-provider", ignoredProvider); + + const preparedElements = { + ...AppElementsWire.createMetadataEntries({ + interception: null, + interceptionContext: null, + layoutIds: [usedLayoutId], + rootLayoutTreePath: "/shared", + routeId: "route:/target", + slotBindings: [], + sourcePage: "/target/page", + }), + [AppElementsWire.keys.skippedLayoutIds]: [usedLayoutId], + } as never; + const targetUrl = "/target?_rsc=target"; + prefetchRscResponse( + targetUrl, + Promise.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } })), + null, + null, + undefined, + { + prepareSnapshot: async () => preparedElements, + retainedLayoutClaims: [ + { layoutId: usedLayoutId, provider: usedProvider }, + { layoutId: ignoredLayoutId, provider: ignoredProvider }, + ], + }, + ); + await cache.get(targetUrl)?.pending; + + expect(cache.get(targetUrl)?.retainedLayoutClaims).toEqual([ + { layoutId: usedLayoutId, provider: usedProvider }, + ]); + expect(cache.get(targetUrl)?.retainedLayoutDependencies).toEqual([usedProvider]); + }); + + it("discards a retained-layout prefetch when provider reconstruction fails", async () => { + const cache = getPrefetchCache(); + const layoutId = "layout:/shared"; + const provider: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot: { + buffer: new ArrayBuffer(1), + contentType: "text/x-component", + layoutIds: [layoutId], + mountedSlotsHeader: null, + paramsHeader: null, + renderedPathAndSearch: null, + url: "/provider", + }, + timestamp: Date.now(), + }; + cache.set("/provider", provider); + const targetUrl = "/target?_rsc=failed-provider"; + prefetchRscResponse( + targetUrl, + Promise.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } })), + null, + null, + undefined, + { + prepareSnapshot: async () => { + throw new Error("provider expired during preparation"); + }, + retainedLayoutClaims: [{ layoutId, provider }], + }, + ); + await cache.get(targetUrl)?.pending; + + expect(cache.has(targetUrl)).toBe(false); + }); + + it("evicts payloads that depend on an evicted retained-layout provider", () => { + const cache = getPrefetchCache(); + const prefetched = getPrefetchedUrls(); + const snapshot = { + buffer: new ArrayBuffer(1), + contentType: "text/x-component", + mountedSlotsHeader: null, + paramsHeader: null, + renderedPathAndSearch: null, + url: "/provider", + }; + const provider: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutProvider: true, + snapshot: { ...snapshot, layoutIds: ["layout:/shared"] }, + timestamp: Date.now(), + }; + const dependent: PrefetchCacheEntry = { + cacheForNavigation: true, + expiresAt: Date.now() + 60_000, + outcome: "cache-seeded", + retainedLayoutDependencies: [provider], + snapshot: { ...snapshot, url: "/dependent" }, + timestamp: Date.now(), + }; + cache.set("/provider", provider); + cache.set("/dependent", dependent); + prefetched.add("/provider"); + prefetched.add("/dependent"); + + expect(consumePrefetchResponse("/provider", null, null)).not.toBeNull(); + expect(cache.has("/dependent")).toBe(false); + }); + it("preserves the original expiry when consuming a prefetched response", () => { const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f6ff508b5a..16469d99b1 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -4259,6 +4259,23 @@ describe("next/headers shim", () => { setHeadersContext(null); }); + it("suspends draftMode() in static instant shells", async () => { + const { draftMode, headersContextFromRequest, runWithHeadersContext } = + await import("../packages/vinext/src/shims/headers.js"); + const { createInstantPrefetchShellState, runWithInstantPrefetchShellState } = + await import("../packages/vinext/src/shims/instant-prefetch-shell.js"); + const context = headersContextFromRequest(new Request("https://example.test/draft")); + const shellState = createInstantPrefetchShellState("/draft", "static"); + + const pending = runWithHeadersContext(context, () => + runWithInstantPrefetchShellState(shellState, () => draftMode()), + ); + expect(shellState.hasDynamicBoundary).toBe(true); + const settled = expect(pending).rejects.toThrow(); + shellState.dynamicAbortController.abort(); + await settled; + }); + it("cookies().toString() URL-encodes request cookie values", async () => { const { setHeadersContext, cookies } = await import("../packages/vinext/src/shims/headers.js"); setHeadersContext({ @@ -6810,6 +6827,82 @@ describe('"use cache" runtime', () => { } }); + it("does not deadlock a static instant shell when a public cache reads draftMode()", async () => { + // Regression coverage for Next.js: + // test/e2e/app-dir/prefetch-true-instant/prefetch-true-instant.test.ts + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { draftMode, headersContextFromRequest, runWithHeadersContext } = + await import("../packages/vinext/src/shims/headers.js"); + const { createInstantPrefetchShellState, runWithInstantPrefetchShellState } = + await import("../packages/vinext/src/shims/instant-prefetch-shell.js"); + setCacheHandler(new MemoryCacheHandler()); + + const cached = registerCachedFunction( + async () => (await draftMode()).isEnabled, + "test:instant-static-draft-cache", + ); + const context = headersContextFromRequest(new Request("https://example.test/draft")); + const shellState = createInstantPrefetchShellState("/draft", "static"); + const pending = runWithHeadersContext(context, () => + runWithInstantPrefetchShellState(shellState, () => cached()), + ); + + try { + await expect( + Promise.race([ + pending, + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]), + ).resolves.toBe(false); + expect(shellState.pendingCacheTasks).toBe(0); + expect(shellState.hasDynamicBoundary).toBe(false); + } finally { + shellState.dynamicAbortController.abort(); + await pending.catch(() => {}); + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("keeps params readable while keying and executing a public cache in a static instant shell", async () => { + // Params are an admitted cache argument. Reading their thenable properties + // for the key and inside the cache body must not create a staged hole. + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createInstantPrefetchShellState, runWithInstantPrefetchShellState } = + await import("../packages/vinext/src/shims/instant-prefetch-shell.js"); + const { makeThenableParams } = await import("../packages/vinext/src/shims/thenable-params.js"); + setCacheHandler(new MemoryCacheHandler()); + + const cached = registerCachedFunction( + async (params: { slug: string }) => params.slug, + "test:instant-static-params-cache", + ); + const shellState = createInstantPrefetchShellState("/blog/:slug", "static"); + const pending = runWithInstantPrefetchShellState(shellState, () => + cached(makeThenableParams({ slug: "post" })), + ); + + try { + await expect( + Promise.race([ + pending, + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 100)), + ]), + ).resolves.toBe("post"); + expect(shellState.pendingCacheTasks).toBe(0); + expect(shellState.hasDynamicBoundary).toBe(false); + } finally { + shellState.dynamicAbortController.abort(); + await pending.catch(() => {}); + setCacheHandler(new MemoryCacheHandler()); + } + }); + it("scopes shared cache entries by build ID", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); @@ -7178,6 +7271,44 @@ describe('"use cache" runtime', () => { expect(r3).toEqual({ count: 2 }); }); + it("defers cold and warm private caches before tracking static instant-shell tasks", async () => { + const { registerCachedFunction, runWithPrivateCache } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { createInstantPrefetchShellState, runWithInstantPrefetchShellState } = + await import("../packages/vinext/src/shims/instant-prefetch-shell.js"); + + let callCount = 0; + const cached = registerCachedFunction( + async () => { + callCount++; + return callCount; + }, + "test:instant-static-private-cache", + "private", + ); + + await runWithPrivateCache(async () => { + const coldState = createInstantPrefetchShellState("/private", "static"); + const cold = runWithInstantPrefetchShellState(coldState, () => cached()); + expect(coldState.hasDynamicBoundary).toBe(true); + expect(coldState.pendingCacheTasks).toBe(0); + expect(callCount).toBe(0); + coldState.dynamicAbortController.abort(); + await expect(cold).rejects.toThrow(); + + expect(await cached()).toBe(1); + + const warmState = createInstantPrefetchShellState("/private", "static"); + const warm = runWithInstantPrefetchShellState(warmState, () => cached()); + expect(warmState.hasDynamicBoundary).toBe(true); + expect(warmState.pendingCacheTasks).toBe(0); + expect(callCount).toBe(1); + warmState.dynamicAbortController.abort(); + await expect(warm).rejects.toThrow(); + expect(callCount).toBe(1); + }); + }); + it("private variant marks prerender output dynamic", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); diff --git a/tests/thenable-params.test.ts b/tests/thenable-params.test.ts index 44d9ae311c..58c327b71c 100644 --- a/tests/thenable-params.test.ts +++ b/tests/thenable-params.test.ts @@ -5,6 +5,10 @@ import { runWithPprFallbackShellState, } from "../packages/vinext/src/shims/ppr-fallback-shell.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; +import { + createInstantPrefetchShellState, + runWithInstantPrefetchShellState, +} from "../packages/vinext/src/shims/instant-prefetch-shell.js"; describe("makeThenableParams", () => { it("is awaitable even when params contain then", async () => { @@ -156,6 +160,49 @@ describe("makeThenableParams", () => { expect(observedKeys).toEqual([["slug"]]); }); + it("suspends params in static instant shells", async () => { + const state = createInstantPrefetchShellState("/blog/:slug", "static"); + let suspension: Promise | undefined; + + runWithInstantPrefetchShellState(state, () => { + const params = makeThenableParams({ slug: "post" }); + try { + Reflect.get(params, "slug"); + } catch (error) { + suspension = error as Promise; + } + }); + + expect(suspension).toBeDefined(); + expect(state.hasDynamicBoundary).toBe(true); + const settled = suspension!.catch(() => {}); + state.dynamicAbortController.abort(); + await settled; + }); + + it("keeps params readable in runtime instant shells", () => { + const state = createInstantPrefetchShellState("/blog/:slug", "runtime"); + + runWithInstantPrefetchShellState(state, () => { + const params = makeThenableParams({ slug: "post" }); + expect(params.slug).toBe("post"); + }); + + expect(state.hasDynamicBoundary).toBe(false); + }); + + it("suspends awaited thenable data in static instant shells", async () => { + const state = createInstantPrefetchShellState("/search", "static"); + const pending = runWithInstantPrefetchShellState(state, () => + makeThenableParams({ q: "vinext" }).then(() => "resolved"), + ); + + expect(state.hasDynamicBoundary).toBe(true); + const settled = pending.catch(() => "aborted"); + state.dynamicAbortController.abort(); + await expect(settled).resolves.toBe("aborted"); + }); + it("suspends only fallback params during cacheComponents fallback-shell prerendering", () => { const state = createPprFallbackShellState({ fallbackParamNames: ["slug"],