From 6ab36a853fe6b246818081ece30e78792595044b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 04:19:38 +0100 Subject: [PATCH 1/8] fix(app-router): support runtime instant prefetch shells --- packages/vinext/src/build/report.ts | 114 ++++++++++++++++ .../vinext/src/client/vinext-next-data.ts | 1 + .../vinext/src/entries/app-browser-entry.ts | 1 + packages/vinext/src/index.ts | 28 +++- .../vinext/src/routing/app-route-graph.ts | 104 ++++++++++++++ .../vinext/src/server/app-browser-entry.ts | 43 ++++-- .../src/server/app-optimistic-routing.ts | 89 +++++++++++- packages/vinext/src/server/app-page-render.ts | 45 ++++++- .../vinext/src/server/app-page-response.ts | 7 + .../vinext/src/server/app-rsc-render-mode.ts | 8 ++ .../src/server/app-rsc-response-finalizer.ts | 21 ++- packages/vinext/src/server/headers.ts | 3 + packages/vinext/src/shims/cache-runtime.ts | 10 +- .../src/shims/instant-prefetch-shell.ts | 127 ++++++++++++++++++ .../internal/app-route-prefetch-policy.ts | 41 ++++-- packages/vinext/src/shims/link.tsx | 38 ++++-- packages/vinext/src/shims/navigation.ts | 50 +++++-- packages/vinext/src/shims/server.ts | 5 + tests/app-optimistic-routing.test.ts | 52 +++++++ tests/app-page-render.test.ts | 59 ++++++++ tests/app-route-graph.test.ts | 61 +++++++++ tests/app-rsc-response-finalizer.test.ts | 34 +++++ tests/build-report.test.ts | 58 ++++++++ tests/entry-templates.test.ts | 13 ++ tests/link-navigation.test.ts | 36 +++++ tests/link.test.ts | 29 ++++ tests/pages-router.test.ts | 51 +++++++ tests/prefetch-cache.test.ts | 30 +++++ 28 files changed, 1098 insertions(+), 60 deletions(-) create mode 100644 packages/vinext/src/shims/instant-prefetch-shell.ts diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index db187927f8..76b105b0fd 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -145,6 +145,95 @@ 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 = unwrapStaticExpression(candidate.value); + return value.type === "Literal" && value.value === expectedValue; + } + return false; +} + +export type NamedExternalReexport = { + importedName: string; + source: string; +}; + +/** 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; + + 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 +280,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 +316,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. diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index 04bbc1dba1..de40863eaf 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -11,6 +11,7 @@ import { isUnknownRecord } from "../utils/record.js"; export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; documentOnly?: 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 e9c0d4134d..a7126f5e9c 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -115,6 +115,7 @@ export function toLinkPrefetchRoute( ): VinextLinkPrefetchRoute { return { canPrefetchLoadingShell: hasLoadingBoundary(route, hasSiblingInterceptLoading), + ...(route.hasRuntimeInstant ? { hasRuntimeInstant: true } : {}), patternParts: [...route.patternParts], isDynamic: route.isDynamic, ...(requiresDynamicNavigationRequest(route) ? { requiresDynamicNavigationRequest: true } : {}), diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2ed778e8fc..d237a18d77 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -26,6 +26,7 @@ import { invalidateAppRouteCache, matchAppRoute, } from "./routing/app-router.js"; +import { isRuntimeInstantConfigDependency } from "./routing/app-route-graph.js"; import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { buildViteResolveExtensions, @@ -4405,14 +4406,20 @@ export const loadServerActionClient = ${ } } + function invalidateAppBrowserEntry() { + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById(RESOLVED_APP_BROWSER_ENTRY); + if (mod) env.moduleGraph.invalidateModule(mod); + } + } + function invalidateHybridClientEntries() { if (!hasAppDir || !hasPagesDir) return; 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_CLIENT_ENTRY); + if (mod) env.moduleGraph.invalidateModule(mod); } + invalidateAppBrowserEntry(); server.ws.send({ type: "full-reload" }); } @@ -4638,6 +4645,19 @@ export const loadServerActionClient = ${ ) { invalidatePagesClientAssetsModule(); } + if ( + hasAppDir && + ((toSlash(filePath).startsWith(`${appDir}/`) && + (fileMatcher.isPageFile(filePath) || SCRIPT_IMPORT_RE.test(filePath))) || + isRuntimeInstantConfigDependency(filePath)) + ) { + // Route metadata such as `unstable_instant` is content-derived and + // may also be supplied by a local re-export. Rebuild both route + // entries when any source module under app/ changes so dev + // prefetch policy cannot retain the old classification. + invalidateAppRoutingModules(); + invalidateAppBrowserEntry(); + } }); server.watcher.on("unlink", (filePath: string) => { updatePublicFileRoute(filePath, false); diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index a9236e608f..248d151b4d 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -11,6 +11,7 @@ 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 { findNamedExternalReexport, hasNamedExportObjectStringProperty } from "../build/report.js"; type InterceptingRoute = { /** Graph-owned identity for this interception edge. */ @@ -231,6 +232,8 @@ 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; /** Parameter names for dynamic segments */ params: string[]; /** Dynamic parameter names captured by the route's root layout. */ @@ -239,6 +242,100 @@ export type AppRoute = { patternParts: string[]; }; +const ROUTE_CONFIG_MODULE_EXTENSIONS = [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mts", + ".mjs", + ".cts", + ".cjs", +]; +const runtimeInstantConfigDependencies = new Set(); + +export function isRuntimeInstantConfigDependency(filePath: string): boolean { + return runtimeInstantConfigDependencies.has(toSlash(filePath)); +} + +function resolveLocalRouteConfigModule(importer: string, source: string): string | null { + if (!source.startsWith(".")) return null; + + 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}`), + ); + } + + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + // Try the next source-resolution candidate. + } + } + return null; +} + +function routeModuleExportHasRuntimeInstant( + filePath: string, + exportName: string, + visited: Set, +): boolean { + const visitKey = `${filePath}\0${exportName}`; + if (visited.has(visitKey)) return false; + visited.add(visitKey); + + try { + const source = fs.readFileSync(filePath, "utf8"); + if (hasNamedExportObjectStringProperty(source, exportName, "prefetch", "runtime")) { + return true; + } + + const reexport = findNamedExternalReexport(source, exportName); + if (reexport === null) return false; + const reexportPath = resolveLocalRouteConfigModule(filePath, reexport.source); + 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 true; + } + runtimeInstantConfigDependencies.add(toSlash(reexportPath)); + return routeModuleExportHasRuntimeInstant(reexportPath, reexport.importedName, visited); + } catch { + return false; + } +} + +export function routeModuleHasRuntimeInstant(filePath: string | null): boolean { + return ( + filePath !== null && routeModuleExportHasRuntimeInstant(filePath, "unstable_instant", new Set()) + ); +} + +function routeHasRuntimeInstant(route: AppRoute): boolean { + return ( + routeModuleHasRuntimeInstant(route.pagePath) || + route.layouts.some(routeModuleHasRuntimeInstant) || + route.parallelSlots.some( + (slot) => + routeModuleHasRuntimeInstant(slot.pagePath) || + routeModuleHasRuntimeInstant(slot.layoutPath ?? null) || + (slot.configLayoutPaths ?? []).some(routeModuleHasRuntimeInstant), + ) + ); +} + export type AppRouteSemanticIds = { route: string; page: string | null; @@ -1074,6 +1171,13 @@ 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) { + route.hasRuntimeInstant = routeHasRuntimeInstant(route); + } + // Sort: static routes first, then dynamic, then catch-all sortRoutes(routes); diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f5be6d0cd6..7f7c47f088 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, @@ -482,24 +483,44 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { createFromFetch(Promise.resolve(restoreRscResponse(options.entry.snapshot))), ); 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 +550,7 @@ async function learnOptimisticRouteTemplatesFromPrefetchCache(options: { mountedSlotsHeader: options.mountedSlotsHeader, routeManifest: options.routeManifest, }) - .then((learned) => { - if (learned) optimisticRouteTemplateSources.add(sourceKey); - }) + .then(() => {}) .finally(() => { optimisticRouteTemplateLearning.delete(sourceKey); }); diff --git a/packages/vinext/src/server/app-optimistic-routing.ts b/packages/vinext/src/server/app-optimistic-routing.ts index c1f04f5e66..c28091176f 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); +} + +export 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, }; } @@ -376,13 +445,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-render.ts b/packages/vinext/src/server/app-page-render.ts index 2ebbe09b7d..d47f70f2b5 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -38,7 +38,10 @@ 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, + type AppRscRenderMode, +} from "./app-rsc-render-mode.js"; import { createArtifactCompatibilityEnvelope, createArtifactCompatibilityGraphVersion, @@ -737,7 +740,36 @@ 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.prerenderToReadableStream + ) { + const { + beginInstantPrefetchShellFinalRender, + createInstantPrefetchShellState, + getInstantPrefetchShellReactSignal, + runWithInstantPrefetchShellState, + wasInstantPrefetchShellAborted, + } = await import("vinext/shims/instant-prefetch-shell"); + const shellState = createInstantPrefetchShellState(options.cleanPathname); + // 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 `connection()` content 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 +852,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 +868,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 && @@ -860,6 +896,7 @@ export async function renderAppPageLifecycle( middlewareContext: options.middlewareContext, mountedSlotsHeader: options.mountedSlotsHeader, params: options.navigationParams, + partialShell: instantPrefetchShellWasAborted, policy: rscResponsePolicy, renderedPathAndSearch: options.renderedPathAndSearch, requestCacheLife: requestCacheLifeForPrerender, @@ -928,7 +965,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 8dada96442..1eaf8d0928 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -11,6 +11,7 @@ import { VINEXT_PARAMS_HEADER, VINEXT_PRERENDER_CACHE_LIFE_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_TIMING_HEADER, } from "./headers.js"; @@ -81,6 +82,7 @@ type BuildAppPageRscResponseOptions = { middlewareContext: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; params?: Record; + partialShell?: boolean; policy: AppPageResponsePolicy; renderedPathAndSearch?: string | null; requestCacheLife?: AppPagePrerenderCacheLife | null; @@ -353,6 +355,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-rsc-render-mode.ts b/packages/vinext/src/server/app-rsc-render-mode.ts index a45e6b7e64..4729d8b749 100644 --- a/packages/vinext/src/server/app-rsc-render-mode.ts +++ b/packages/vinext/src/server/app-rsc-render-mode.ts @@ -2,12 +2,15 @@ export type AppRscRenderMode = | "navigation" | "prefetch-empty" | "prefetch-dynamic-shell" + | "prefetch-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_LOADING_SHELL = "prefetch-loading-shell" satisfies AppRscRenderMode; @@ -19,6 +22,9 @@ 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_LOADING_SHELL) { return "prefetch-loading-shell"; } @@ -32,6 +38,8 @@ 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_LOADING_SHELL: return APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL; case null: 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/headers.ts b/packages/vinext/src/server/headers.ts index c655180035..be6e8a82f5 100644 --- a/packages/vinext/src/server/headers.ts +++ b/packages/vinext/src/server/headers.ts @@ -81,6 +81,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/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index dbc23f4700..15d479f556 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -50,6 +50,7 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { isDraftModeEnabled, markDynamicUsage } from "./headers.js"; +import { trackInstantPrefetchShellCacheTask } from "./instant-prefetch-shell.js"; import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; import { isMarkedAppPagePropsObject } from "./internal/app-page-props-cache-key.js"; @@ -451,6 +452,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. @@ -478,7 +486,7 @@ export function registerCachedFunction( const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development"; const cachedFn = (...args: TArgs): Promise => - trackPprFallbackShellCacheTask(async (): Promise => { + trackCacheTaskForShells(async (): Promise => { const rsc = await getRscModule(); const keySeed = getUseCacheKeySeed(); const captures = options.decryptCaptures ? await options.decryptCaptures(args[0]) : undefined; 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..0eb2a73681 --- /dev/null +++ b/packages/vinext/src/shims/instant-prefetch-shell.ts @@ -0,0 +1,127 @@ +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; +}; + +const instantPrefetchShellAls = getOrCreateAls( + "vinext.instantPrefetchShell.als", +); + +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): InstantPrefetchShellState { + return { + dynamicAbortController: new AbortController(), + hasDynamicBoundary: false, + isFinalRenderStarted: false, + pendingAbortCleanup: null, + pendingCacheTasks: 0, + reactAbortController: new AbortController(), + route, + }; +} + +export function runWithInstantPrefetchShellState( + state: InstantPrefetchShellState, + fn: () => T, +): T { + return instantPrefetchShellAls.run(state, 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()"); +} 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 44a51ae4ae..0932697248 100644 --- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -43,6 +43,8 @@ export type AppRoutePrefetchPolicy = { * between `auto` and `full` in `getPrefetchEntryCacheStatus`. */ honorDynamicStaleTime: boolean; + /** Render a runtime `unstable_instant` shell that preserves completed Suspense branches. */ + prefetchInstantShell?: true; prefetchShellFirst: boolean; shouldPrefetch: boolean; }; @@ -71,23 +73,39 @@ 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: true, + 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(); // A search-param href renders query-specific output, so its payload can only // ever be a shell — never reusable by a navigation to the same route. const hasSearchParams = new URL(routeHref, "http://vinext.local").search !== ""; @@ -108,7 +126,8 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic }; } -export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy { +export function resolveFullAppRoutePrefetch(href: string): AppRoutePrefetchPolicy { + if (resolveMatchedAppRoute(href)?.hasRuntimeInstant) return runtimeInstantPolicy(); return { cacheForNavigation: true, fallbackTtl: "static", diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index b6c947c39b..be8149299b 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -422,6 +422,7 @@ function prefetchUrl( rscCacheBusting, { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, }, headersModule, @@ -480,12 +481,14 @@ 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 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 +503,15 @@ 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, + renderMode: isInstantShellPrefetch + ? 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); @@ -758,8 +763,13 @@ function prefetchUrl( ? DYNAMIC_NAVIGATION_CACHE_TTL : PREFETCH_CACHE_TTL, honorDynamicStaleTime: autoPrefetch.honorDynamicStaleTime, + instantShell: isInstantShellPrefetch, optimisticRouteShell: isOptimisticRouteShellPrefetch, - prefetchKind: isOptimisticRouteShellPrefetch ? "loading-shell" : "navigation", + prefetchKind: isInstantShellPrefetch + ? "instant-shell" + : isOptimisticRouteShellPrefetch + ? "loading-shell" + : "navigation", prepareSnapshot: autoPrefetch.cacheForNavigation ? prepareNavigationPrefetchSnapshot : undefined, @@ -834,7 +844,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 a698766125..c6ff3146e5 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -47,6 +47,8 @@ import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_RSC_COMPLETION_METADATA_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 +65,10 @@ 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, + 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 { @@ -323,7 +328,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 +336,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; @@ -955,7 +962,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; @@ -1019,14 +1031,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 { @@ -1319,6 +1333,7 @@ export function prefetchRscResponse( cacheForNavigation?: boolean; fallbackTtlMs?: number; honorDynamicStaleTime?: boolean; + instantShell?: boolean; optimisticRouteShell?: boolean; prefetchKind?: PrefetchCacheKind; prepareSnapshot?: (snapshot: CachedRscResponse) => Promise; @@ -1337,12 +1352,17 @@ 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, timestamp: now, }; @@ -1352,6 +1372,10 @@ export function prefetchRscResponse( entry.pending = fetchPromise .then(async (response) => { if (response.ok) { + if (response.headers.get(VINEXT_RSC_PARTIAL_SHELL_HEADER) === "1") { + entry.cacheForNavigation = false; + entry.partialSuspenseShell = true; + } const snapshot = await snapshotRscResponse(response); if (cache.get(cacheKey) !== entry) return; const previousSize = getPrefetchCacheEntrySize(entry); @@ -2710,9 +2734,12 @@ 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, APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL); + } // The call-time header snapshot defaults to AUTO/learning semantics. // A full reusable prefetch is the one policy that suppresses this header. if (reusable && kind === "full") { @@ -2788,8 +2815,9 @@ const _appRouter: AppRouterInstance = { } : { cacheForNavigation: false, - optimisticRouteShell: true, - prefetchKind: "navigation", + instantShell: policy.prefetchInstantShell, + optimisticRouteShell: !policy.prefetchInstantShell, + prefetchKind: policy.prefetchInstantShell ? "instant-shell" : "navigation", }, ); })() 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/tests/app-optimistic-routing.test.ts b/tests/app-optimistic-routing.test.ts index b8366fb1cb..4ce564a178 100644 --- a/tests/app-optimistic-routing.test.ts +++ b/tests/app-optimistic-routing.test.ts @@ -283,6 +283,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-render.test.ts b/tests/app-page-render.test.ts index 7a1e737329..28fed4d6d7 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, @@ -48,6 +49,11 @@ 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 } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + suspendInstantPrefetchConnection, + trackInstantPrefetchShellCacheTask, +} from "../packages/vinext/src/shims/instant-prefetch-shell.js"; function captureRecord(value: ReactNode | AppOutgoingElements): Record { if (!isAppElementsRecord(value)) { @@ -408,6 +414,59 @@ 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("returns pre-render special responses before starting the render stream", async () => { const common = createCommonOptions(); diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 48991d2cb9..147124d1ec 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -120,6 +120,67 @@ 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" };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "layout-instant/layout.tsx", + `export const unstable_instant = { prefetch: "runtime" };\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" };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "static-instant/page.tsx", + `export const unstable_instant = { prefetch: "static" };\n${EMPTY_PAGE}`, + ); + await writeAppFile( + appDir, + "reexported-instant/config.ts", + 'export const config = { prefetch: "runtime" };\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" }; 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").hasRuntimeInstant).toBe(false); + expect(findRoute(graph.routes, "/plain").hasRuntimeInstant).toBe(false); + }); + }); + 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-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..7edb1ea646 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -10,8 +10,10 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; import { + findNamedExternalReexport, hasExportedName, hasNamedExport, + hasNamedExportObjectStringProperty, extractExportConstString, extractExportConstNumber, extractGetStaticPropsRevalidate, @@ -108,6 +110,62 @@ 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); + }); + + 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" }); + }); +}); + // ─── extractExportConstString ───────────────────────────────────────────────── describe("extractExportConstString", () => { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index eb2a4c3239..d77dd9d1bf 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -414,6 +414,19 @@ describe("App Router generated manifest construction", () => { ).toBe(false); }); + it("advertises runtime instant routes to the browser prefetch policy", () => { + expect( + toLinkPrefetchRoute({ + ...minimalAppRoutes[1], + hasRuntimeInstant: true, + }), + ).toMatchObject({ + canPrefetchLoadingShell: false, + hasRuntimeInstant: true, + patternParts: ["about"], + }); + }); + 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 428fc71696..76e4c2be5a 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -10,6 +10,7 @@ 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, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { @@ -62,6 +63,12 @@ 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, + hasRuntimeInstant: true, + patternParts: ["instant-target"], + isDynamic: false, + }, { canPrefetchLoadingShell: false, patternParts: ["teams", ":team", "dashboard"], @@ -2218,6 +2225,35 @@ describe("Link prefetch scheduling", () => { } }); + it("downgrades explicit full prefetches to runtime instant shells", 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"); + 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 fb9218d827..e0fa260492 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -396,6 +396,35 @@ 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: true, + prefetchShellFirst: false, + shouldPrefetch: true, + }); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + } + }); }); // ─── resolveHref (internal helper, tested via component output) ───────── diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index eb352c463e..4860a488b1 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3700,6 +3700,57 @@ 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.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 })], + server: { port: 0 }, + logLevel: "silent", + }); + + try { + const resolved = await testServer.pluginContainer.resolveId( + "virtual:vinext-app-browser-entry", + ); + expect(resolved).toBeTruthy(); + 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" };\n' + + 'export const marker = "updated";\n', + ); + testServer.watcher.emit("change", configPath); + + expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + } 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 390cf514da..87bd911c70 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -19,6 +19,7 @@ import { VINEXT_STALE_TIME_PENDING_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, } from "../packages/vinext/src/server/headers.js"; import { appendRscCompletionMetadata } from "../packages/vinext/src/server/rsc-completion-metadata.js"; @@ -396,6 +397,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"; From c1663d30f990e59c49130b5c028ce449afe2060e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 04:27:02 +0100 Subject: [PATCH 2/8] fix(app-router): keep instant shell helpers private --- packages/vinext/src/routing/app-route-graph.ts | 2 +- packages/vinext/src/server/app-optimistic-routing.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 248d151b4d..4de571cbaa 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -317,7 +317,7 @@ function routeModuleExportHasRuntimeInstant( } } -export function routeModuleHasRuntimeInstant(filePath: string | null): boolean { +function routeModuleHasRuntimeInstant(filePath: string | null): boolean { return ( filePath !== null && routeModuleExportHasRuntimeInstant(filePath, "unstable_instant", new Set()) ); diff --git a/packages/vinext/src/server/app-optimistic-routing.ts b/packages/vinext/src/server/app-optimistic-routing.ts index c28091176f..49a25cdcc5 100644 --- a/packages/vinext/src/server/app-optimistic-routing.ts +++ b/packages/vinext/src/server/app-optimistic-routing.ts @@ -352,7 +352,7 @@ function sanitizeInstantShellValue(value: unknown): unknown { return cloneElement(value, props as Record); } -export function sanitizeInstantShellElements(elements: AppElements): AppElements { +function sanitizeInstantShellElements(elements: AppElements): AppElements { const sanitized: Record = { ...elements }; for (const [elementId, value] of Object.entries(elements)) { if (AppElementsWire.parseElementKey(elementId) !== null) { From 295ad5a9183250544d67f679930892f3a1c8e5c5 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 04:31:27 +0100 Subject: [PATCH 3/8] fix(app-router): refresh instant route metadata precisely --- packages/vinext/src/build/report.ts | 2 +- packages/vinext/src/index.ts | 9 ++++--- .../vinext/src/routing/app-route-graph.ts | 2 ++ tests/app-route-graph.test.ts | 25 +++++++++++++++++++ tests/build-report.test.ts | 8 ++++++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 76b105b0fd..d31546d9a6 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -177,7 +177,7 @@ export function hasNamedExportObjectStringProperty( ) { continue; } - const value = unwrapStaticExpression(candidate.value); + const value = resolveLocalConstExpression(program, candidate.value, new Set()); return value.type === "Literal" && value.value === expectedValue; } return false; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index d237a18d77..44d2533a02 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4444,6 +4444,7 @@ export const loadServerActionClient = ${ invalidateMetadataFileCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); + invalidateAppBrowserEntry(); } let hybridRouteValidation: Promise = Promise.resolve(); @@ -4648,15 +4649,15 @@ export const loadServerActionClient = ${ if ( hasAppDir && ((toSlash(filePath).startsWith(`${appDir}/`) && - (fileMatcher.isPageFile(filePath) || SCRIPT_IMPORT_RE.test(filePath))) || + (fileMatcher.isAppRouterPage(filePath) || fileMatcher.isAppLayoutFile(filePath))) || isRuntimeInstantConfigDependency(filePath)) ) { // Route metadata such as `unstable_instant` is content-derived and // may also be supplied by a local re-export. Rebuild both route - // entries when any source module under app/ changes so dev - // prefetch policy cannot retain the old classification. + // entries when a config-bearing route module or a followed config + // dependency changes so dev prefetch policy cannot retain the old + // classification. invalidateAppRoutingModules(); - invalidateAppBrowserEntry(); } }); server.watcher.on("unlink", (filePath: string) => { diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 4de571cbaa..eebfcbf8e2 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -1042,6 +1042,8 @@ export async function buildAppRouteGraph( appDir: string, matcher: ValidFileMatcher, ): Promise<{ routes: AppRouteGraphRoute[]; routeManifest: RouteManifest }> { + runtimeInstantConfigDependencies.clear(); + // 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). diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 147124d1ec..9aadf3f229 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -7,6 +7,7 @@ import { toSlash } from "pathslash"; import { buildAppRouteGraph, findOwnerRouteForDir, + isRuntimeInstantConfigDependency, type AppRouteGraphRoute, type RouteManifest, } from "../packages/vinext/src/routing/app-route-graph.js"; @@ -181,6 +182,30 @@ describe("App Router route graph builder", () => { }); }); + 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" };\n', + ); + + await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect(isRuntimeInstantConfigDependency(configPath)).toBe(true); + + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + await buildAppRouteGraph(appDir, createValidFileMatcher()); + expect(isRuntimeInstantConfigDependency(configPath)).toBe(false); + }); + }); + 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/build-report.test.ts b/tests/build-report.test.ts index 7edb1ea646..1d8dd990a6 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -136,6 +136,14 @@ describe("hasNamedExportObjectStringProperty", () => { "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", () => { From 352ea7a6e9bca848d4db3f23316896c1e802d53a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 04:42:28 +0100 Subject: [PATCH 4/8] fix(app-router): close instant prefetch parity gaps --- .../vinext/src/client/vinext-next-data.ts | 1 + .../vinext/src/entries/app-browser-entry.ts | 17 ++- packages/vinext/src/index.ts | 43 +++++-- .../vinext/src/routing/app-route-graph.ts | 115 ++++++++++++------ packages/vinext/src/routing/app-router.ts | 1 + .../internal/app-route-prefetch-policy.ts | 16 ++- tests/app-route-graph.test.ts | 30 ++++- tests/entry-templates.test.ts | 11 ++ tests/link.test.ts | 29 +++++ tests/pages-router.test.ts | 11 +- 10 files changed, 217 insertions(+), 57 deletions(-) diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index de40863eaf..58a5a6f2ed 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -11,6 +11,7 @@ import { isUnknownRecord } from "../utils/record.js"; export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; documentOnly?: boolean; + hasInstant?: boolean; hasRuntimeInstant?: boolean; isDynamic: boolean; patternParts: string[]; diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index a7126f5e9c..3a984676f1 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 = true, ): 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,7 @@ export function toLinkPrefetchRoute( ): VinextLinkPrefetchRoute { return { canPrefetchLoadingShell: hasLoadingBoundary(route, hasSiblingInterceptLoading), + ...(route.hasInstant ? { hasInstant: true } : {}), ...(route.hasRuntimeInstant ? { hasRuntimeInstant: true } : {}), patternParts: [...route.patternParts], isDynamic: route.isDynamic, @@ -123,7 +125,18 @@ 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 = true, +): VinextLinkPrefetchRoute[] { + if (!cacheComponents) { + const instantRoute = routes.find((route) => route.hasInstant); + 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/index.ts b/packages/vinext/src/index.ts index 44d2533a02..67c045668f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -26,7 +26,6 @@ import { invalidateAppRouteCache, matchAppRoute, } from "./routing/app-router.js"; -import { isRuntimeInstantConfigDependency } from "./routing/app-route-graph.js"; import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { buildViteResolveExtensions, @@ -1432,6 +1431,10 @@ 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 publicDirConflictOptions: Parameters[0] | null = null; let rscCompatibilityId: string | undefined; let draftModeSecret = getPagesPreviewModeId(); @@ -1519,9 +1522,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); + instantConfigDependencies = graph.instantConfigDependencies; + appPrefetchRoutes = toLinkPrefetchRoutes(graph.routes, nextConfig.cacheComponents); + } return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { appPrefetchRoutes, instrumentationClientPath, @@ -3862,6 +3868,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); + instantConfigDependencies = graph.instantConfigDependencies; // 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. @@ -3889,6 +3896,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { graph.routeManifest, pagesPrefetchRoutes, nextConfig.rewrites, + nextConfig.cacheComponents, ); } if (id === RESOLVED_APP_CAPABILITIES && hasAppDir) { @@ -4604,10 +4612,27 @@ 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 hasExportedName(fs.readFileSync(filePath, "utf8"), "unstable_instant"); + } catch { + return false; + } + }; 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 ( @@ -4629,6 +4654,8 @@ export const loadServerActionClient = ${ invalidateAppRoutingModules(); regenerateAppRouteTypes(); routeChanged = true; + } else if (instantConfigChanged) { + invalidateAppRoutingModules(); } if (routeChanged) { invalidatePagesServerEntry(); @@ -4648,9 +4675,8 @@ export const loadServerActionClient = ${ } if ( hasAppDir && - ((toSlash(filePath).startsWith(`${appDir}/`) && - (fileMatcher.isAppRouterPage(filePath) || fileMatcher.isAppLayoutFile(filePath))) || - isRuntimeInstantConfigDependency(filePath)) + (isTrackedInstantConfigDependency(filePath) || + routeModuleNowExportsInstantConfig(filePath)) ) { // Route metadata such as `unstable_instant` is content-derived and // may also be supplied by a local re-export. Rebuild both route @@ -4663,6 +4689,7 @@ export const loadServerActionClient = ${ 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 ( @@ -4684,6 +4711,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 eebfcbf8e2..c4cdd6a3c7 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -11,7 +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 { findNamedExternalReexport, hasNamedExportObjectStringProperty } from "../build/report.js"; +import { + findNamedExternalReexport, + hasExportedName, + hasNamedExportObjectStringProperty, +} from "../build/report.js"; type InterceptingRoute = { /** Graph-owned identity for this interception edge. */ @@ -234,6 +238,8 @@ export type AppRoute = { 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; /** Parameter names for dynamic segments */ params: string[]; /** Dynamic parameter names captured by the route's root layout. */ @@ -252,14 +258,8 @@ const ROUTE_CONFIG_MODULE_EXTENSIONS = [ ".cts", ".cjs", ]; -const runtimeInstantConfigDependencies = new Set(); - -export function isRuntimeInstantConfigDependency(filePath: string): boolean { - return runtimeInstantConfigDependencies.has(toSlash(filePath)); -} - -function resolveLocalRouteConfigModule(importer: string, source: string): string | null { - if (!source.startsWith(".")) return null; +function localRouteConfigModuleCandidates(importer: string, source: string): string[] { + if (!source.startsWith(".")) return []; const base = path.resolve(path.dirname(importer), source); const candidates = [ @@ -275,6 +275,10 @@ function resolveLocalRouteConfigModule(importer: string, source: string): string ); } + return candidates; +} + +function resolveLocalRouteConfigModule(candidates: readonly string[]): string | null { for (const candidate of candidates) { try { if (fs.statSync(candidate).isFile()) return candidate; @@ -285,55 +289,80 @@ function resolveLocalRouteConfigModule(importer: string, source: string): string return null; } -function routeModuleExportHasRuntimeInstant( +type InstantPrefetchMode = "runtime" | "static"; + +function routeModuleExportInstantPrefetchMode( filePath: string, exportName: string, visited: Set, -): boolean { + dependencies: Set, +): InstantPrefetchMode | null { const visitKey = `${filePath}\0${exportName}`; - if (visited.has(visitKey)) return false; + if (visited.has(visitKey)) return null; visited.add(visitKey); try { const source = fs.readFileSync(filePath, "utf8"); + if (hasExportedName(source, exportName)) dependencies.add(toSlash(filePath)); if (hasNamedExportObjectStringProperty(source, exportName, "prefetch", "runtime")) { - return true; + return "runtime"; + } + if (hasNamedExportObjectStringProperty(source, exportName, "prefetch", "static")) { + return "static"; } const reexport = findNamedExternalReexport(source, exportName); - if (reexport === null) return false; - const reexportPath = resolveLocalRouteConfigModule(filePath, reexport.source); + 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 true; + return "runtime"; } - runtimeInstantConfigDependencies.add(toSlash(reexportPath)); - return routeModuleExportHasRuntimeInstant(reexportPath, reexport.importedName, visited); + return routeModuleExportInstantPrefetchMode( + reexportPath, + reexport.importedName, + visited, + dependencies, + ); } catch { - return false; + return null; } } -function routeModuleHasRuntimeInstant(filePath: string | null): boolean { - return ( - filePath !== null && routeModuleExportHasRuntimeInstant(filePath, "unstable_instant", new Set()) - ); -} - -function routeHasRuntimeInstant(route: AppRoute): boolean { - return ( - routeModuleHasRuntimeInstant(route.pagePath) || - route.layouts.some(routeModuleHasRuntimeInstant) || - route.parallelSlots.some( - (slot) => - routeModuleHasRuntimeInstant(slot.pagePath) || - routeModuleHasRuntimeInstant(slot.layoutPath ?? null) || - (slot.configLayoutPaths ?? []).some(routeModuleHasRuntimeInstant), - ) - ); +function routeModuleInstantPrefetchMode( + filePath: string | null, + dependencies: Set, +): InstantPrefetchMode | null { + return filePath === null + ? null + : routeModuleExportInstantPrefetchMode(filePath, "unstable_instant", new Set(), dependencies); +} + +function routeInstantPrefetchMode( + route: AppRoute, + dependencies: Set, +): InstantPrefetchMode | null { + const modulePaths = [ + route.pagePath, + ...route.layouts, + ...route.parallelSlots.flatMap((slot) => [ + slot.pagePath, + slot.layoutPath ?? null, + ...(slot.configLayoutPaths ?? []), + ]), + ]; + let mode: InstantPrefetchMode | null = null; + for (const modulePath of modulePaths) { + const moduleMode = routeModuleInstantPrefetchMode(modulePath, dependencies); + if (moduleMode === "runtime") return "runtime"; + if (moduleMode === "static") mode = "static"; + } + return mode; } export type AppRouteSemanticIds = { @@ -1041,8 +1070,12 @@ function createRouteManifestGraphVersion(segmentGraph: StaticSegmentGraph): Grap export async function buildAppRouteGraph( appDir: string, matcher: ValidFileMatcher, -): Promise<{ routes: AppRouteGraphRoute[]; routeManifest: RouteManifest }> { - runtimeInstantConfigDependencies.clear(); +): Promise<{ + routes: AppRouteGraphRoute[]; + routeManifest: RouteManifest; + instantConfigDependencies: Set; +}> { + const instantConfigDependencies = new Set(); // 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) @@ -1177,13 +1210,15 @@ export async function buildAppRouteGraph( // inherited layout and active parallel-slot configs propagate to every // concrete route advertised to the browser prefetch policy. for (const route of routes) { - route.hasRuntimeInstant = routeHasRuntimeInstant(route); + const instantMode = routeInstantPrefetchMode(route, instantConfigDependencies); + route.hasInstant = instantMode !== null; + route.hasRuntimeInstant = instantMode === "runtime"; } // 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/shims/internal/app-route-prefetch-policy.ts b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts index 0932697248..23d8eb1258 100644 --- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -109,7 +109,7 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic // A search-param href renders query-specific output, so its payload can only // ever be a shell — never reusable by a navigation to the same route. const hasSearchParams = new URL(routeHref, "http://vinext.local").search !== ""; - return { + const policy: AppRoutePrefetchPolicy = { // Vinext does not yet have Next.js's per-segment runtime-prefetch hints. // Routes with loading boundaries prefetch a shell first so navigation can // commit loading.js immediately. Dynamic routes without loading-shell @@ -124,10 +124,22 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic prefetchShellFirst: hasSearchParams || !route.isDynamic, shouldPrefetch: true, }; + if (route.hasInstant) { + // Next can reuse the static segments independently. Vinext's prefetch + // cache is still monolithic, so retain the shell only for optimistic + // learning and require the click-time request for dynamic branches. + policy.cacheForNavigation = false; + } + return policy; } export function resolveFullAppRoutePrefetch(href: string): AppRoutePrefetchPolicy { - if (resolveMatchedAppRoute(href)?.hasRuntimeInstant) return runtimeInstantPolicy(); + const route = resolveMatchedAppRoute(href); + if (route?.hasRuntimeInstant) return runtimeInstantPolicy(); + // Next ignores the explicit "full" strategy for any truthy + // `unstable_instant` config. Static instant routes use the ordinary PPR + // strategy; runtime instant routes use the learning-only shell above. + if (route?.hasInstant) return resolveAutoAppRoutePrefetch(href); return { cacheForNavigation: true, fallbackTtl: "static", diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 9aadf3f229..7bb35b709c 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -7,7 +7,6 @@ import { toSlash } from "pathslash"; import { buildAppRouteGraph, findOwnerRouteForDir, - isRuntimeInstantConfigDependency, type AppRouteGraphRoute, type RouteManifest, } from "../packages/vinext/src/routing/app-route-graph.js"; @@ -177,7 +176,9 @@ describe("App Router route graph builder", () => { 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, "/plain").hasInstant).toBe(false); expect(findRoute(graph.routes, "/plain").hasRuntimeInstant).toBe(false); }); }); @@ -197,12 +198,31 @@ describe("App Router route graph builder", () => { 'export const unstable_instant = { prefetch: "runtime" };\n', ); - await buildAppRouteGraph(appDir, createValidFileMatcher()); - expect(isRuntimeInstantConfigDependency(configPath)).toBe(true); + 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); - await buildAppRouteGraph(appDir, createValidFileMatcher()); - expect(isRuntimeInstantConfigDependency(configPath)).toBe(false); + 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); }); }); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index d77dd9d1bf..fac1e1c5ac 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -418,15 +418,26 @@ describe("App Router generated manifest construction", () => { 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: true, hasRuntimeInstant: false }], + false, + ), + ).toThrow(/without enabling `cacheComponents`/); + }); + it("does not advertise an already-shared root loading boundary for nested static routes", () => { const route = { ...minimalAppRoutes[0], diff --git a/tests/link.test.ts b/tests/link.test.ts index e0fa260492..7fc535eb6c 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, @@ -425,6 +426,34 @@ describe("Link App Router prefetch mode", () => { 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, + prefetchShellFirst: true, + shouldPrefetch: true, + }); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + } + }); }); // ─── resolveHref (internal helper, tested via component output) ───────── diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 4860a488b1..c5ec237aae 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3721,7 +3721,7 @@ describe("Virtual server entry generation", () => { const testServer = await createServer({ root: tmpDir, configFile: false, - plugins: [vinext({ appDir: tmpDir })], + plugins: [vinext({ appDir: tmpDir, nextConfig: { cacheComponents: true } })], server: { port: 0 }, logLevel: "silent", }); @@ -3745,6 +3745,15 @@ describe("Virtual server entry generation", () => { testServer.watcher.emit("change", configPath); expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + + fs.rmSync(configPath); + testServer.watcher.emit("unlink", configPath); + // Missing relative re-exports are conservatively runtime instant. + expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + + fs.writeFileSync(configPath, 'export const unstable_instant = { prefetch: "static" };\n'); + testServer.watcher.emit("add", configPath); + expect(await loadCode()).not.toContain('"hasRuntimeInstant":true'); } finally { await testServer.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); From 3741e820c89cbdfb6bdf18b5a62a2a2abff9f34b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 04:50:41 +0100 Subject: [PATCH 5/8] fix(app-router): validate and cache instant metadata --- packages/vinext/src/build/report.ts | 68 +++++++++ .../vinext/src/entries/app-browser-entry.ts | 12 +- packages/vinext/src/index.ts | 9 +- .../vinext/src/routing/app-route-graph.ts | 138 ++++++++++++------ .../internal/app-route-prefetch-policy.ts | 24 ++- tests/app-route-graph.test.ts | 40 +++++ tests/build-report.test.ts | 29 ++++ tests/entry-templates.test.ts | 24 ++- tests/link-navigation.test.ts | 36 +++++ tests/link.test.ts | 3 +- tests/pages-router.test.ts | 22 +++ 11 files changed, 346 insertions(+), 59 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index d31546d9a6..a1bab8a1dc 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -188,6 +188,67 @@ export type NamedExternalReexport = { source: string; }; +export type NamedExportObjectStringPropertyAnalysis = { + hasExport: boolean; + hasUseClientDirective: boolean; + 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, + propertyValue: null, + reexport: null, + }; + } + + const reexport = findNamedExternalReexportInProgram(program, name); + const localName = findExportedLocalNameInProgram(program, name); + 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()); + 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", + ), + propertyValue, + reexport, + }; +} + /** Returns the local module specifier that supplies a named re-export. */ export function findNamedExternalReexport( code: string, @@ -196,6 +257,13 @@ export function findNamedExternalReexport( 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" || diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 3a984676f1..a480d7370f 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -24,7 +24,7 @@ export function generateBrowserEntry( beforeFiles: [], fallback: [], }, - cacheComponents = true, + cacheComponents = false, ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const reactInstanceBootstrapPath = resolveClientRuntimeModule("react-instance-bootstrap"); @@ -127,10 +127,16 @@ export function toLinkPrefetchRoute( /** Project App routes together so sibling-intercept loading is applied to its target route. */ export function toLinkPrefetchRoutes( routes: readonly AppRoute[], - cacheComponents = true, + 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.hasInstant); + const instantRoute = routes.find((route) => route.hasInstantConfig); if (instantRoute) { throw new Error( `Page "${instantRoute.pattern}" cannot use \`export const unstable_instant = ...\` without enabling \`cacheComponents\`.`, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 67c045668f..36d547e798 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4421,12 +4421,16 @@ export const loadServerActionClient = ${ } } - function invalidateHybridClientEntries() { - if (!hasAppDir || !hasPagesDir) return; + 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" }); } @@ -4453,6 +4457,7 @@ export const loadServerActionClient = ${ invalidateRscEntryModule(); invalidateRootParamsModule(); invalidateAppBrowserEntry(); + if (hasPagesDir) invalidatePagesClientEntry(); } let hybridRouteValidation: Promise = Promise.resolve(); diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index c4cdd6a3c7..537be131c3 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -12,9 +12,8 @@ import { findFileWithExts, scanWithExtensions, type ValidFileMatcher } from "./f import { validateRoutePatterns } from "./route-validation.js"; import { compareStrings } from "../utils/compare.js"; import { - findNamedExternalReexport, - hasExportedName, - hasNamedExportObjectStringProperty, + analyzeNamedExportObjectStringProperty, + type NamedExportObjectStringPropertyAnalysis, } from "../build/report.js"; type InterceptingRoute = { @@ -240,6 +239,10 @@ export type AppRoute = { 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. */ @@ -290,63 +293,95 @@ function resolveLocalRouteConfigModule(candidates: readonly string[]): string | } type InstantPrefetchMode = "runtime" | "static"; +type InstantModuleAnalysisCache = Map; + +function readInstantModuleAnalysis( + filePath: string, + exportName: string, + cache: InstantModuleAnalysisCache, +): NamedExportObjectStringPropertyAnalysis | null { + const key = `${filePath}\0${exportName}`; + const cached = cache.get(key); + if (cached !== undefined) return cached; + + try { + const analysis = analyzeNamedExportObjectStringProperty( + fs.readFileSync(filePath, "utf8"), + exportName, + "prefetch", + ); + cache.set(key, analysis); + return analysis; + } catch { + cache.set(key, null); + return null; + } +} function routeModuleExportInstantPrefetchMode( filePath: string, exportName: string, visited: Set, dependencies: Set, + analysisCache: InstantModuleAnalysisCache, ): InstantPrefetchMode | null { const visitKey = `${filePath}\0${exportName}`; if (visited.has(visitKey)) return null; visited.add(visitKey); - try { - const source = fs.readFileSync(filePath, "utf8"); - if (hasExportedName(source, exportName)) dependencies.add(toSlash(filePath)); - if (hasNamedExportObjectStringProperty(source, exportName, "prefetch", "runtime")) { - return "runtime"; - } - if (hasNamedExportObjectStringProperty(source, exportName, "prefetch", "static")) { - return "static"; - } - - const reexport = findNamedExternalReexport(source, exportName); - 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, - visited, - dependencies, - ); - } catch { - return null; - } + const analysis = readInstantModuleAnalysis(filePath, exportName, analysisCache); + if (analysis === null) return null; + if (analysis.hasExport) dependencies.add(toSlash(filePath)); + 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, + visited, + dependencies, + analysisCache, + ); } function routeModuleInstantPrefetchMode( filePath: string | null, dependencies: Set, + analysisCache: InstantModuleAnalysisCache, ): InstantPrefetchMode | null { return filePath === null ? null - : routeModuleExportInstantPrefetchMode(filePath, "unstable_instant", new Set(), dependencies); + : routeModuleExportInstantPrefetchMode( + filePath, + "unstable_instant", + new Set(), + dependencies, + analysisCache, + ); } -function routeInstantPrefetchMode( +function routeInstantConfigMetadata( route: AppRoute, dependencies: Set, -): InstantPrefetchMode | null { + analysisCache: InstantModuleAnalysisCache, +): { + hasConfig: boolean; + hasConfigInClientModule: boolean; + mode: InstantPrefetchMode | null; +} { const modulePaths = [ route.pagePath, ...route.layouts, @@ -357,12 +392,20 @@ function routeInstantPrefetchMode( ]), ]; let mode: InstantPrefetchMode | null = null; + let hasConfig = false; + let hasConfigInClientModule = false; for (const modulePath of modulePaths) { - const moduleMode = routeModuleInstantPrefetchMode(modulePath, dependencies); - if (moduleMode === "runtime") return "runtime"; - if (moduleMode === "static") mode = "static"; + if (modulePath === null) continue; + const analysis = readInstantModuleAnalysis(modulePath, "unstable_instant", analysisCache); + if (analysis?.hasExport) { + hasConfig = true; + if (analysis.hasUseClientDirective) hasConfigInClientModule = true; + } + const moduleMode = routeModuleInstantPrefetchMode(modulePath, dependencies, analysisCache); + if (moduleMode === "runtime") mode = "runtime"; + else if (moduleMode === "static" && mode === null) mode = "static"; } - return mode; + return { hasConfig, hasConfigInClientModule, mode }; } export type AppRouteSemanticIds = { @@ -1076,6 +1119,7 @@ export async function buildAppRouteGraph( instantConfigDependencies: Set; }> { const instantConfigDependencies = new Set(); + const instantModuleAnalysisCache: InstantModuleAnalysisCache = 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) @@ -1210,9 +1254,15 @@ export async function buildAppRouteGraph( // inherited layout and active parallel-slot configs propagate to every // concrete route advertised to the browser prefetch policy. for (const route of routes) { - const instantMode = routeInstantPrefetchMode(route, instantConfigDependencies); - route.hasInstant = instantMode !== null; - route.hasRuntimeInstant = instantMode === "runtime"; + 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 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 23d8eb1258..9807482889 100644 --- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -96,6 +96,20 @@ function runtimeInstantPolicy(): AppRoutePrefetchPolicy { }; } +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: true, + prefetchShellFirst: false, + shouldPrefetch: true, + }; +} + export function canAutoPrefetchFullAppRoute(href: string): boolean { return resolveAutoAppRoutePrefetch(href).cacheForNavigation; } @@ -106,6 +120,7 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic const route = resolveMatchedAppRoute(href); if (!route) return NO_APP_ROUTE_PREFETCH; if (route.hasRuntimeInstant) return runtimeInstantPolicy(); + if (route.hasInstant) return staticInstantPolicy(); // A search-param href renders query-specific output, so its payload can only // ever be a shell — never reusable by a navigation to the same route. const hasSearchParams = new URL(routeHref, "http://vinext.local").search !== ""; @@ -124,12 +139,6 @@ export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolic prefetchShellFirst: hasSearchParams || !route.isDynamic, shouldPrefetch: true, }; - if (route.hasInstant) { - // Next can reuse the static segments independently. Vinext's prefetch - // cache is still monolithic, so retain the shell only for optimistic - // learning and require the click-time request for dynamic branches. - policy.cacheForNavigation = false; - } return policy; } @@ -137,8 +146,7 @@ export function resolveFullAppRoutePrefetch(href: string): AppRoutePrefetchPolic const route = resolveMatchedAppRoute(href); if (route?.hasRuntimeInstant) return runtimeInstantPolicy(); // Next ignores the explicit "full" strategy for any truthy - // `unstable_instant` config. Static instant routes use the ordinary PPR - // strategy; runtime instant routes use the learning-only shell above. + // `unstable_instant` config. if (route?.hasInstant) return resolveAutoAppRoutePrefetch(href); return { cacheForNavigation: true, diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 7bb35b709c..97427e949d 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"; @@ -146,6 +147,16 @@ describe("App Router route graph builder", () => { "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" };\n${EMPTY_PAGE}`, + ); await writeAppFile( appDir, "reexported-instant/config.ts", @@ -178,6 +189,9 @@ describe("App Router route graph builder", () => { 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); }); @@ -226,6 +240,32 @@ describe("App Router route graph builder", () => { }); }); + 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" };\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/build-report.test.ts b/tests/build-report.test.ts index 1d8dd990a6..0ff6739e63 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -10,6 +10,7 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; import { + analyzeNamedExportObjectStringProperty, findNamedExternalReexport, hasExportedName, hasNamedExport, @@ -174,6 +175,34 @@ describe("hasNamedExportObjectStringProperty", () => { }); }); +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, + propertyValue: null, + reexport: null, + }); + expect( + analyzeNamedExportObjectStringProperty( + 'export { config as unstable_instant } from "./config";', + "unstable_instant", + "prefetch", + ), + ).toMatchObject({ + hasExport: true, + propertyValue: null, + reexport: { importedName: "config", source: "./config" }, + }); + }); +}); + // ─── extractExportConstString ───────────────────────────────────────────────── describe("extractExportConstString", () => { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index fac1e1c5ac..228c5f7406 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -432,12 +432,34 @@ describe("App Router generated manifest construction", () => { it("rejects unstable_instant routes when Cache Components are disabled", () => { expect(() => toLinkPrefetchRoutes( - [{ ...minimalAppRoutes[1], hasInstant: true, hasRuntimeInstant: false }], + [ + { + ...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 76e4c2be5a..5a97d57c07 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -65,10 +65,17 @@ const linkPrefetchRoutes = [ { 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"], @@ -2254,6 +2261,35 @@ describe("Link prefetch scheduling", () => { } }); + 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_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 7fc535eb6c..e52191b968 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -446,7 +446,8 @@ describe("Link App Router prefetch mode", () => { cacheForNavigation: false, fallbackTtl: "static", honorDynamicStaleTime: true, - prefetchShellFirst: true, + prefetchInstantShell: true, + prefetchShellFirst: false, shouldPrefetch: true, }); } finally { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index c5ec237aae..bd1503de2c 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3706,6 +3706,11 @@ describe("Virtual server entry generation", () => { 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"), @@ -3731,6 +3736,21 @@ describe("Virtual server entry generation", () => { "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 loadCode = async () => { const loaded = await testServer.pluginContainer.load(resolved!.id); return typeof loaded === "string" ? loaded : ((loaded as any)?.code ?? ""); @@ -3745,6 +3765,8 @@ describe("Virtual server entry generation", () => { testServer.watcher.emit("change", configPath); expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + expect(getClientModuleById).toHaveBeenCalledWith(pagesClientResolved!.id); + expect(invalidateClientModule).toHaveBeenCalledWith(pagesClientModule); fs.rmSync(configPath); testServer.watcher.emit("unlink", configPath); From 7066d1aab14159a1c59908fa316897b2b6aa2deb Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 06:21:05 +0100 Subject: [PATCH 6/8] fix(app-router): complete instant prefetch cache parity --- packages/vinext/src/build/report.ts | 71 +++-- packages/vinext/src/index.ts | 60 +++- .../vinext/src/routing/app-route-graph.ts | 174 +++++++++++- .../src/server/app-page-element-builder.ts | 35 ++- packages/vinext/src/server/app-page-render.ts | 16 +- .../src/server/app-page-route-wiring.tsx | 28 +- .../src/server/app-render-dependency.tsx | 6 +- .../vinext/src/server/app-rsc-render-mode.ts | 8 + packages/vinext/src/shims/cache-runtime.ts | 94 ++++--- packages/vinext/src/shims/cache.ts | 67 ++--- packages/vinext/src/shims/fetch-cache.ts | 266 ++++++++++-------- packages/vinext/src/shims/headers.ts | 13 + .../src/shims/instant-prefetch-shell.ts | 68 ++++- .../internal/app-route-prefetch-policy.ts | 8 +- packages/vinext/src/shims/link.tsx | 7 +- packages/vinext/src/shims/navigation.ts | 10 +- packages/vinext/src/shims/thenable-params.ts | 35 +++ tests/app-page-element-builder.test.ts | 63 ++++- tests/app-page-render.test.ts | 167 ++++++++++- tests/app-page-route-wiring.test.ts | 39 +++ tests/app-route-graph.test.ts | 126 ++++++++- tests/build-report.test.ts | 39 +++ tests/link-navigation.test.ts | 3 +- tests/link.test.ts | 4 +- tests/pages-router.test.ts | 51 +++- tests/shims.test.ts | 131 +++++++++ tests/thenable-params.test.ts | 47 ++++ 27 files changed, 1362 insertions(+), 274 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a1bab8a1dc..d18dbfad32 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -191,6 +191,8 @@ export type NamedExternalReexport = { export type NamedExportObjectStringPropertyAnalysis = { hasExport: boolean; hasUseClientDirective: boolean; + hasStaticValue: boolean; + staticValue: unknown; propertyValue: string | null; reexport: NamedExternalReexport | null; }; @@ -206,6 +208,8 @@ export function analyzeNamedExportObjectStringProperty( return { hasExport: false, hasUseClientDirective: false, + hasStaticValue: false, + staticValue: undefined, propertyValue: null, reexport: null, }; @@ -213,6 +217,8 @@ export function analyzeNamedExportObjectStringProperty( 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 = @@ -220,6 +226,13 @@ export function analyzeNamedExportObjectStringProperty( 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 ( @@ -244,6 +257,8 @@ export function analyzeNamedExportObjectStringProperty( hasUseClientDirective: program.body.some( (node) => node.type === "ExpressionStatement" && node.directive === "use client", ), + hasStaticValue, + staticValue, propertyValue, reexport, }; @@ -472,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 ( @@ -491,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/index.ts b/packages/vinext/src/index.ts index 36d547e798..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, @@ -1435,6 +1436,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // 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(); @@ -1525,7 +1543,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let appPrefetchRoutes: ReturnType = []; if (hasAppDir) { const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); - instantConfigDependencies = graph.instantConfigDependencies; + captureInstantRouteMetadata(graph); appPrefetchRoutes = toLinkPrefetchRoutes(graph.routes, nextConfig.cacheComponents); } return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { @@ -3868,7 +3886,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); - instantConfigDependencies = graph.instantConfigDependencies; + 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. @@ -4628,11 +4646,37 @@ export const loadServerActionClient = ${ return false; } try { - return hasExportedName(fs.readFileSync(filePath, "utf8"), "unstable_instant"); + 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); @@ -4684,11 +4728,11 @@ export const loadServerActionClient = ${ routeModuleNowExportsInstantConfig(filePath)) ) { // Route metadata such as `unstable_instant` is content-derived and - // may also be supplied by a local re-export. Rebuild both route - // entries when a config-bearing route module or a followed config - // dependency changes so dev prefetch policy cannot retain the old - // classification. - invalidateAppRoutingModules(); + // 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) => { diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 537be131c3..a6b400d99c 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -11,6 +11,7 @@ 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, @@ -293,7 +294,120 @@ function resolveLocalRouteConfigModule(candidates: readonly string[]): string | } type InstantPrefetchMode = "runtime" | "static"; -type InstantModuleAnalysisCache = Map; +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, @@ -301,19 +415,29 @@ function readInstantModuleAnalysis( cache: InstantModuleAnalysisCache, ): NamedExportObjectStringPropertyAnalysis | null { const key = `${filePath}\0${exportName}`; - const cached = cache.get(key); + 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( - fs.readFileSync(filePath, "utf8"), - exportName, - "prefetch", - ); - cache.set(key, analysis); + const analysis = analyzeNamedExportObjectStringProperty(source, exportName, "prefetch"); + cache.analyses.set(key, analysis); return analysis; } catch { - cache.set(key, null); + cache.analyses.set(key, null); return null; } } @@ -321,6 +445,7 @@ function readInstantModuleAnalysis( function routeModuleExportInstantPrefetchMode( filePath: string, exportName: string, + routePattern: string, visited: Set, dependencies: Set, analysisCache: InstantModuleAnalysisCache, @@ -332,6 +457,12 @@ function routeModuleExportInstantPrefetchMode( 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; } @@ -351,6 +482,7 @@ function routeModuleExportInstantPrefetchMode( return routeModuleExportInstantPrefetchMode( reexportPath, reexport.importedName, + routePattern, visited, dependencies, analysisCache, @@ -359,6 +491,7 @@ function routeModuleExportInstantPrefetchMode( function routeModuleInstantPrefetchMode( filePath: string | null, + routePattern: string, dependencies: Set, analysisCache: InstantModuleAnalysisCache, ): InstantPrefetchMode | null { @@ -367,6 +500,7 @@ function routeModuleInstantPrefetchMode( : routeModuleExportInstantPrefetchMode( filePath, "unstable_instant", + routePattern, new Set(), dependencies, analysisCache, @@ -398,10 +532,25 @@ function routeInstantConfigMetadata( 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, dependencies, analysisCache); + const moduleMode = routeModuleInstantPrefetchMode( + modulePath, + route.pattern, + dependencies, + analysisCache, + ); if (moduleMode === "runtime") mode = "runtime"; else if (moduleMode === "static" && mode === null) mode = "static"; } @@ -1119,7 +1268,10 @@ export async function buildAppRouteGraph( instantConfigDependencies: Set; }> { const instantConfigDependencies = new Set(); - const instantModuleAnalysisCache: InstantModuleAnalysisCache = new Map(); + 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) diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index 311bc8b7d4..6177eb259d 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -33,7 +33,11 @@ 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_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 +50,7 @@ import { createAppPageRenderDependency, invokeAppComponent, isAppRenderSuspension, + isClientReferenceAppComponent, isReactOwnedAppComponent, renderAfterAppDependencies, type AppPageRenderDependency, @@ -489,7 +494,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 +529,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); } @@ -638,7 +659,7 @@ export async function buildPageElements< ); siblingInterceptElement = createElement( LayoutComponent, - { params: makeThenableParams(interceptLayoutParams) }, + { params: makeComponentParams(LayoutComponent, interceptLayoutParams) }, siblingInterceptElement, ); } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index d47f70f2b5..32e1609890 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -40,6 +40,7 @@ import { } from "./app-page-stream.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 { @@ -743,7 +744,8 @@ export async function renderAppPageLifecycle( 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_INSTANT_SHELL || + options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL) && options.prerenderToReadableStream ) { const { @@ -753,11 +755,17 @@ export async function renderAppPageLifecycle( runWithInstantPrefetchShellState, wasInstantPrefetchShellAborted, } = await import("vinext/shims/instant-prefetch-shell"); - const shellState = createInstantPrefetchShellState(options.cleanPathname); - // Runtime instant shells stay open until every Cache Component already + 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 `connection()` content into the payload. + // 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, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index cb53a5a19a..0f77db1071 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 { @@ -248,7 +250,11 @@ type BuildAppPageRouteElementOptions< slotOverrides?: Readonly>> | null; }; -type MakeThenableParams = (params: AppPageParams, observer?: ThenableParamsObserver) => unknown; +type MakeThenableParams = ( + params: AppPageParams, + observer?: ThenableParamsObserver, + options?: ThenableParamsOptions, +) => unknown; type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, @@ -813,6 +819,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 +1173,8 @@ export function buildAppPageElements< }); const layoutProps: Record = { - params: options.makeThenableParams( + params: makeComponentParams( + layoutComponent, layoutParams, options.layoutParamAccess?.createThenableParamsObserver(layoutEntry.id), ), @@ -1270,7 +1288,7 @@ export function buildAppPageElements< )!; slotElement = ; } else { - const slotThenableParams = options.makeThenableParams(slotParams); + const slotThenableParams = makeComponentParams(slotComponent!, slotParams); const slotProps: Record = { params: slotThenableParams, }; @@ -1413,7 +1431,7 @@ export function buildAppPageElements< const layoutEntry = layoutEntriesAtPosition[layoutIndex]; const LayoutComponent = layoutEntry.component; slotElement = ( - + {slotElement} ); 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-render-mode.ts b/packages/vinext/src/server/app-rsc-render-mode.ts index 4729d8b749..935b37d3ac 100644 --- a/packages/vinext/src/server/app-rsc-render-mode.ts +++ b/packages/vinext/src/server/app-rsc-render-mode.ts @@ -3,6 +3,7 @@ export type AppRscRenderMode = | "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; @@ -11,6 +12,8 @@ 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; @@ -25,6 +28,9 @@ export function getRscRenderModeCacheVariant(mode: AppRscRenderMode): string | n 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"; } @@ -40,6 +46,8 @@ export function parseAppRscRenderMode(value: string | null): AppRscRenderMode { 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/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 15d479f556..9f84ead2c2 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -50,7 +50,10 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { isDraftModeEnabled, markDynamicUsage } from "./headers.js"; -import { trackInstantPrefetchShellCacheTask } from "./instant-prefetch-shell.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"; @@ -485,8 +488,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 => - trackCacheTaskForShells(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; @@ -507,30 +519,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); @@ -538,11 +552,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. @@ -690,6 +699,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. @@ -854,6 +864,18 @@ type CachedFunctionResult = { effectiveLife: CacheLifeConfig; }; +function createCacheContext(variant: string): CacheContext { + return { + tags: [], + lifeConfigs: [], + variant: variant || "default", + hasExplicitRevalidate: false, + hasExplicitExpire: false, + dynamicNestedCacheError: undefined, + invalidDynamicUsageError: undefined, + }; +} + // oxlint-disable-next-line @typescript-eslint/no-explicit-any async function runCachedFunctionWithContext Promise>( fn: T, @@ -889,15 +911,7 @@ async function runCachedFunctionWithContext Promis } } - const ctx: CacheContext = { - tags: [], - lifeConfigs: [], - variant: variant || "default", - hasExplicitRevalidate: false, - hasExplicitExpire: false, - dynamicNestedCacheError: undefined, - invalidDynamicUsageError: undefined, - }; + const ctx = createCacheContext(variant); const result = await cacheContextStorage.run(ctx, () => fn(...args)); 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 index 0eb2a73681..07fc93db93 100644 --- a/packages/vinext/src/shims/instant-prefetch-shell.ts +++ b/packages/vinext/src/shims/instant-prefetch-shell.ts @@ -9,12 +9,40 @@ export type InstantPrefetchShellState = { pendingCacheTasks: number; reactAbortController: AbortController; route: string; + stage: "runtime" | "static"; }; const instantPrefetchShellAls = getOrCreateAls( "vinext.instantPrefetchShell.als", ); +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; @@ -63,7 +91,10 @@ function scheduleAbortIfReady(state: InstantPrefetchShellState): void { }); } -export function createInstantPrefetchShellState(route: string): InstantPrefetchShellState { +export function createInstantPrefetchShellState( + route: string, + stage: "runtime" | "static" = "runtime", +): InstantPrefetchShellState { return { dynamicAbortController: new AbortController(), hasDynamicBoundary: false, @@ -72,6 +103,7 @@ export function createInstantPrefetchShellState(route: string): InstantPrefetchS pendingCacheTasks: 0, reactAbortController: new AbortController(), route, + stage, }; } @@ -125,3 +157,37 @@ export function suspendInstantPrefetchConnection(): Promise | null { 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(); + if (!state || 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(); + if (!state || 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 9807482889..9749cbe9d4 100644 --- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -43,8 +43,8 @@ export type AppRoutePrefetchPolicy = { * between `auto` and `full` in `getPrefetchEntryCacheStatus`. */ honorDynamicStaleTime: boolean; - /** Render a runtime `unstable_instant` shell that preserves completed Suspense branches. */ - prefetchInstantShell?: true; + /** Render the configured `unstable_instant` shell stage. */ + prefetchInstantShell?: "runtime" | "static"; prefetchShellFirst: boolean; shouldPrefetch: boolean; }; @@ -90,7 +90,7 @@ function runtimeInstantPolicy(): AppRoutePrefetchPolicy { cacheForNavigation: false, fallbackTtl: "dynamic", honorDynamicStaleTime: true, - prefetchInstantShell: true, + prefetchInstantShell: "runtime", prefetchShellFirst: false, shouldPrefetch: true, }; @@ -104,7 +104,7 @@ function staticInstantPolicy(): AppRoutePrefetchPolicy { cacheForNavigation: false, fallbackTtl: "static", honorDynamicStaleTime: true, - prefetchInstantShell: true, + prefetchInstantShell: "static", prefetchShellFirst: false, shouldPrefetch: true, }; diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index be8149299b..e294b2716f 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -424,6 +424,7 @@ function prefetchUrl( 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, @@ -504,7 +505,9 @@ function prefetchUrl( fetchPriority: priority, prefetchKind: mode === "full" ? "full" : "auto", renderMode: isInstantShellPrefetch - ? APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL + ? 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 @@ -763,7 +766,7 @@ function prefetchUrl( ? DYNAMIC_NAVIGATION_CACHE_TTL : PREFETCH_CACHE_TTL, honorDynamicStaleTime: autoPrefetch.honorDynamicStaleTime, - instantShell: isInstantShellPrefetch, + instantShell: isInstantShellPrefetch !== undefined, optimisticRouteShell: isOptimisticRouteShellPrefetch, prefetchKind: isInstantShellPrefetch ? "instant-shell" diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index c6ff3146e5..f4a65e4480 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -67,6 +67,7 @@ import { assertSafeNavigationUrl } from "./url-safety.js"; import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.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"; @@ -2738,7 +2739,12 @@ const _appRouter: AppRouterInstance = { : resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? fullHref); const reusable = policy.shouldPrefetch && policy.cacheForNavigation; if (policy.prefetchInstantShell) { - headers.set(VINEXT_RSC_RENDER_MODE_HEADER, APP_RSC_RENDER_MODE_PREFETCH_INSTANT_SHELL); + 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, + ); } // The call-time header snapshot defaults to AUTO/learning semantics. // A full reusable prefetch is the one policy that suppresses this header. @@ -2815,7 +2821,7 @@ const _appRouter: AppRouterInstance = { } : { cacheForNavigation: false, - instantShell: policy.prefetchInstantShell, + instantShell: policy.prefetchInstantShell !== undefined, optimisticRouteShell: !policy.prefetchInstantShell, prefetchKind: policy.prefetchInstantShell ? "instant-shell" : "navigation", }, 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-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 28fed4d6d7..0ba40c6fad 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -36,7 +36,10 @@ import { 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 { @@ -49,9 +52,13 @@ 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 } from "../packages/vinext/src/server/app-rsc-render-mode.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"; @@ -467,6 +474,162 @@ describe("app page render lifecycle", () => { 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(); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 7744e08260..213dacba2a 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 diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 97427e949d..112340c6c6 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -127,12 +127,12 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "page-instant/page.tsx", - `export const unstable_instant = { prefetch: "runtime" };\n${EMPTY_PAGE}`, + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, ); await writeAppFile( appDir, "layout-instant/layout.tsx", - `export const unstable_instant = { prefetch: "runtime" };\n${EMPTY_LAYOUT}`, + `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); @@ -140,7 +140,7 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "slot-instant/@team/page.tsx", - `export const unstable_instant = { prefetch: "runtime" };\n${EMPTY_PAGE}`, + `export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, ); await writeAppFile( appDir, @@ -155,12 +155,12 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "client-instant/page.tsx", - `"use client";\nexport const unstable_instant = { prefetch: "runtime" };\n${EMPTY_PAGE}`, + `"use client";\nexport const unstable_instant = { prefetch: "runtime", samples: [{}] };\n${EMPTY_PAGE}`, ); await writeAppFile( appDir, "reexported-instant/config.ts", - 'export const config = { prefetch: "runtime" };\n', + 'export const config = { prefetch: "runtime", samples: [{}] };\n', ); await writeAppFile( appDir, @@ -170,7 +170,7 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "indirect-instant/page.tsx", - `const base = { prefetch: "runtime" }; const config = base; export { config as unstable_instant };\n${EMPTY_PAGE}`, + `const base = { prefetch: "runtime", samples: [{}] }; const config = base; export { config as unstable_instant };\n${EMPTY_PAGE}`, ); await writeAppFile( appDir, @@ -197,6 +197,116 @@ describe("App Router route graph builder", () => { }); }); + 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"); @@ -209,7 +319,7 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "instant-config.ts", - 'export const unstable_instant = { prefetch: "runtime" };\n', + 'export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n', ); let graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); @@ -246,7 +356,7 @@ describe("App Router route graph builder", () => { await writeAppFile( appDir, "layout.tsx", - `export const unstable_instant = { prefetch: "runtime" };\n${EMPTY_LAYOUT}`, + `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); diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 0ff6739e63..5113758d19 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -186,6 +186,8 @@ describe("analyzeNamedExportObjectStringProperty", () => { ).toMatchObject({ hasExport: true, hasUseClientDirective: true, + hasStaticValue: true, + staticValue: false, propertyValue: null, reexport: null, }); @@ -197,10 +199,47 @@ describe("analyzeNamedExportObjectStringProperty", () => { ), ).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 ───────────────────────────────────────────────── diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 5a97d57c07..5afad6de69 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -12,6 +12,7 @@ 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_PREFETCH_HEADER, @@ -2277,7 +2278,7 @@ describe("Link prefetch scheduling", () => { 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, + APP_RSC_RENDER_MODE_PREFETCH_STATIC_INSTANT_SHELL, ); const { getPrefetchCache } = await import("../packages/vinext/src/shims/navigation.js"); expect([...getPrefetchCache().values()][0]).toMatchObject({ diff --git a/tests/link.test.ts b/tests/link.test.ts index e52191b968..24eb5eaf30 100644 --- a/tests/link.test.ts +++ b/tests/link.test.ts @@ -417,7 +417,7 @@ describe("Link App Router prefetch mode", () => { cacheForNavigation: false, fallbackTtl: "dynamic", honorDynamicStaleTime: true, - prefetchInstantShell: true, + prefetchInstantShell: "runtime", prefetchShellFirst: false, shouldPrefetch: true, }); @@ -446,7 +446,7 @@ describe("Link App Router prefetch mode", () => { cacheForNavigation: false, fallbackTtl: "static", honorDynamicStaleTime: true, - prefetchInstantShell: true, + prefetchInstantShell: "static", prefetchShellFirst: false, shouldPrefetch: true, }); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index bd1503de2c..5bf9e89787 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -3751,6 +3751,7 @@ describe("Virtual server entry generation", () => { 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 ?? ""); @@ -3759,23 +3760,65 @@ describe("Virtual server entry generation", () => { fs.writeFileSync( configPath, - 'export const unstable_instant = { prefetch: "runtime" };\n' + + 'export const unstable_instant = { prefetch: "runtime", samples: [{}] };\n' + 'export const marker = "updated";\n', ); testServer.watcher.emit("change", configPath); - expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + 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. - expect(await loadCode()).toContain('"hasRuntimeInstant":true'); + 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); - expect(await loadCode()).not.toContain('"hasRuntimeInstant":true'); + 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 }); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index b0a152bec7..3b64ac9bfd 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({ @@ -6734,6 +6751,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"); @@ -7102,6 +7195,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"], From e10c6f37ed83b0e1706502a231c5f96ab754edd0 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 11:29:32 +0100 Subject: [PATCH 7/8] fix(app-router): reuse complete instant prefetches --- packages/vinext/src/shims/link.tsx | 7 ++- packages/vinext/src/shims/navigation.ts | 27 +++++++++- tests/link-navigation.test.ts | 54 +++++++++++++++++++ tests/prefetch-cache.test.ts | 69 +++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index e294b2716f..3755cc37dd 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -447,6 +447,7 @@ function prefetchUrl( getMountedSlotsHeader, createAppPrefetchRequestHeaders, discardLearningOnlyPrefetchCacheEntry, + hasFreshPrefetchCacheEntry, hasSearchAgnosticPrefetchShellForRoute, hasPrefetchCacheEntryForNavigation, peekPrefetchResponseForNavigation, @@ -539,10 +540,8 @@ function prefetchUrl( if (autoPrefetch.cacheForNavigation) { discardLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext); } - if (prefetched.has(cacheKey)) { - if (!autoPrefetch.cacheForNavigation) { - return; - } + if (prefetched.has(cacheKey) && hasFreshPrefetchCacheEntry(cacheKey)) { + return; } const fetchFullRscPayload = () => scheduleAppPrefetchFetch( diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index f4a65e4480..2a9861d8ee 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -675,6 +675,24 @@ export function hasPrefetchCacheEntryForNavigation( return false; } +/** Check an exact prefetch entry for freshness, regardless of whether navigation may consume it. */ +export function hasFreshPrefetchCacheEntry(cacheKey: string): boolean { + const cache = getPrefetchCache(); + const entry = cache.get(cacheKey); + if (entry === undefined) { + getPrefetchedUrls().delete(cacheKey); + return false; + } + + if (entry.pending !== undefined || resolvePrefetchCacheEntryExpiresAt(entry) > Date.now()) { + touchPrefetchCacheEntry(cache, cacheKey, entry); + return true; + } + + deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, true); + return false; +} + export function hasSearchAgnosticPrefetchShellForRoute( rscUrl: string, interceptionContext: string | null = null, @@ -1376,6 +1394,13 @@ export function prefetchRscResponse( if (response.headers.get(VINEXT_RSC_PARTIAL_SHELL_HEADER) === "1") { entry.cacheForNavigation = false; entry.partialSuspenseShell = true; + } else if (entry.instantShell === true) { + // A runtime instant render that reached the end of the tree is a + // complete navigation payload. Next reuses it directly rather than + // issuing the same request again on click. + entry.cacheForNavigation = true; + entry.instantShell = false; + entry.prefetchKind = "navigation"; } const snapshot = await snapshotRscResponse(response); if (cache.get(cacheKey) !== entry) return; @@ -2787,7 +2812,7 @@ const _appRouter: AppRouterInstance = { ) { return; } - } else if (prefetched.has(cacheKey)) { + } else if (prefetched.has(cacheKey) && hasFreshPrefetchCacheEntry(cacheKey)) { attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate); return; } diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 5afad6de69..d349cb8454 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -17,8 +17,10 @@ import { import { NEXT_ROUTER_PREFETCH_HEADER, NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, + NEXT_ROUTER_STALE_TIME_HEADER, VINEXT_DYNAMIC_STALE_TIME_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, + VINEXT_RSC_PARTIAL_SHELL_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, } from "../packages/vinext/src/server/headers.js"; import type { VinextLinkPrefetchRoute } from "../packages/vinext/src/client/vinext-next-data.js"; @@ -2240,6 +2242,14 @@ describe("Link prefetch scheduling", () => { nodeEnv: "production", props: { prefetch: true }, }); + result.fetch.mockResolvedValue( + new Response("partial instant payload", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_PARTIAL_SHELL_HEADER]: "1", + }, + }), + ); try { observer.dispatchIntersectingEntry(result.anchor); @@ -2262,6 +2272,42 @@ describe("Link prefetch scheduling", () => { } }); + it("refetches a runtime instant route after its server stale time elapses", async () => { + // Ported from Next.js: + // test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts + // "expires runtime prefetches when their stale time has elapsed" + const now = 1_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + const observer = stubIntersectionObserver(); + const result = await renderIsolatedLink({ + href: "/instant-target", + nodeEnv: "production", + }); + result.fetch.mockResolvedValue( + new Response("complete instant payload", { + headers: { + "content-type": "text/x-component", + [NEXT_ROUTER_STALE_TIME_HEADER]: "120", + }, + }), + ); + + try { + observer.dispatchIntersectingEntry(result.anchor); + await waitForFetchCalls(result.fetch, 1); + await flushPrefetchTasks(); + + observer.dispatchIntersectingEntry(result.anchor, false); + vi.mocked(Date.now).mockReturnValue(now + 120_001); + observer.dispatchIntersectingEntry(result.anchor); + 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({ @@ -2269,6 +2315,14 @@ describe("Link prefetch scheduling", () => { nodeEnv: "production", props: { prefetch: true }, }); + result.fetch.mockResolvedValue( + new Response("partial instant payload", { + headers: { + "content-type": "text/x-component", + [VINEXT_RSC_PARTIAL_SHELL_HEADER]: "1", + }, + }), + ); try { observer.dispatchIntersectingEntry(result.anchor); diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 87bd911c70..324897643b 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -243,6 +243,45 @@ describe("prefetch cache eviction", () => { expect(secondInvalidate).toHaveBeenCalledTimes(1); }); + it("router.prefetch refetches a runtime instant route after its stale time elapses", async () => { + // Ported from Next.js: + // test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts + // "expires runtime prefetches when their stale time has elapsed" + (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [ + { + canPrefetchLoadingShell: false, + hasInstant: true, + hasRuntimeInstant: true, + isDynamic: false, + patternParts: ["runtime-instant"], + }, + ]; + let now = 1_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + const fetch = vi.fn( + async () => + new Response("complete instant payload", { + headers: { + "content-type": "text/x-component", + [NEXT_ROUTER_STALE_TIME_HEADER]: "120", + }, + }), + ); + (globalThis as any).fetch = fetch; + + appRouterInstance.prefetch("/runtime-instant"); + await waitForPrefetchSetup( + () => getPrefetchCache().values().next().value?.outcome === "cache-seeded", + ); + expect(fetch).toHaveBeenCalledTimes(1); + + now += 120_001; + appRouterInstance.prefetch("/runtime-instant"); + await settlePrefetchSetup(); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + it("reuses a prefetched response only when mounted-slot context matches", () => { const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); @@ -426,6 +465,36 @@ describe("prefetch cache eviction", () => { expect(consumePrefetchResponse(rscUrl)).toBeNull(); }); + it("reuses a complete runtime instant prefetch for navigation", async () => { + // Ported from Next.js: + // test/e2e/app-dir/segment-cache/prefetch-runtime/prefetch-runtime.test.ts + // "can completely prefetch a page that is fully static" + const rscUrl = "/complete-instant-shell.rsc"; + prefetchRscResponse( + rscUrl, + Promise.resolve( + new Response("complete instant payload", { + headers: { "content-type": "text/x-component" }, + }), + ), + null, + null, + undefined, + { cacheForNavigation: false, instantShell: true }, + ); + + await waitForPrefetchSetup(() => getPrefetchCache().get(rscUrl)?.outcome === "cache-seeded"); + + expect(getPrefetchCache().get(rscUrl)).toMatchObject({ + cacheForNavigation: true, + instantShell: false, + prefetchKind: "navigation", + }); + const consumed = consumePrefetchResponse(rscUrl); + expect(consumed).not.toBeNull(); + await expect(restoreRscResponse(consumed!).text()).resolves.toBe("complete instant payload"); + }); + it("derives the interception context from the current pathname", () => { (globalThis as any).window.location.pathname = "/feed"; From 5d759bf92e6c3b526529df8777f62ababbcfba74 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 10 Aug 2026 11:56:17 +0100 Subject: [PATCH 8/8] fix(app-router): preserve full prefetch upgrades --- packages/vinext/src/shims/link.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 3755cc37dd..ae62b33e6e 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -540,7 +540,11 @@ function prefetchUrl( if (autoPrefetch.cacheForNavigation) { discardLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext); } - if (prefetched.has(cacheKey) && hasFreshPrefetchCacheEntry(cacheKey)) { + if ( + !autoPrefetch.cacheForNavigation && + prefetched.has(cacheKey) && + hasFreshPrefetchCacheEntry(cacheKey) + ) { return; } const fetchFullRscPayload = () =>