diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index 57df8256d..f70397783 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -8,12 +8,26 @@ import type { NEXT_DATA } from "vinext/shims/internal/utils"; import { isUnknownRecord } from "../utils/record.js"; +export type VinextRuntimePrefetchLoadingFallback = { + attributes: Record; + tagName: string; + text: string; +}; + export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; + canPrefetchRuntimeShell?: boolean; + canPrefetchStaticRoute?: boolean; documentOnly?: boolean; isDynamic: boolean; + loadingShellVaryParamNames?: string[]; patternParts: string[]; + prefetchVaryParamNames?: string[]; + prefetchVarySearchParams?: boolean; requiresDynamicNavigationRequest?: boolean; + runtimePrefetchLoadingFallback?: VinextRuntimePrefetchLoadingFallback; + runtimePrefetchVaryParamNames?: string[]; + runtimePrefetchVarySearchParams?: boolean; }; /** diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 9a237a877..cfe13056e 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -1,11 +1,14 @@ import { resolveClientRuntimeModule, resolveRuntimeEntryModule } from "./runtime-entry-module.js"; +import fs from "node:fs"; import type { VinextLinkPrefetchRoute, VinextPagesLinkPrefetchRoute, + VinextRuntimePrefetchLoadingFallback, } from "../client/vinext-next-data.js"; import type { AppRoute } from "../routing/app-router.js"; import type { RouteManifest } from "../routing/app-route-graph.js"; import type { NextRewrite } from "../config/next-config.js"; +import { escapeRegExp } from "../utils/regex.js"; /** * Generate the virtual browser entry module. @@ -65,17 +68,580 @@ export function toDocumentOnlyAppRoute(route: AppRoute): VinextLinkPrefetchRoute }; } -function requiresDynamicNavigationRequest(route: AppRoute): boolean { - return route.isDynamic && route.parallelSlots.length > 0; +function requiresDynamicNavigationRequest(route: AppRoute, vary: PrefetchVaryAnalysis): boolean { + return ( + (route.isDynamic && route.parallelSlots.length > 0) || vary.requiresDynamicNavigationRequest + ); } /** Project an `AppRoute` down to the public `VinextLinkPrefetchRoute` shape. */ export function toLinkPrefetchRoute(route: AppRoute): VinextLinkPrefetchRoute { + const vary = analyzePrefetchVary(route); return { canPrefetchLoadingShell: route.loadingPath !== null, + ...(vary.canPrefetchRuntimeShell ? { canPrefetchRuntimeShell: true } : {}), + ...(vary.canPrefetchStaticRoute || !route.isDynamic ? { canPrefetchStaticRoute: true } : {}), + ...((route.loadingPath !== null || vary.canPrefetchRuntimeShell) && + vary.loadingShellParamNames.length > 0 + ? { loadingShellVaryParamNames: vary.loadingShellParamNames } + : {}), patternParts: [...route.patternParts], + ...(vary.prefetchParamNames.length > 0 + ? { prefetchVaryParamNames: vary.prefetchParamNames } + : {}), + ...(vary.prefetchVarySearchParams ? { prefetchVarySearchParams: true } : {}), + ...(vary.runtimePrefetchParamNames.length > 0 + ? { runtimePrefetchVaryParamNames: vary.runtimePrefetchParamNames } + : {}), + ...(vary.runtimePrefetchLoadingFallback + ? { runtimePrefetchLoadingFallback: vary.runtimePrefetchLoadingFallback } + : {}), + ...(vary.runtimePrefetchVarySearchParams ? { runtimePrefetchVarySearchParams: true } : {}), isDynamic: route.isDynamic, - ...(requiresDynamicNavigationRequest(route) ? { requiresDynamicNavigationRequest: true } : {}), + ...(requiresDynamicNavigationRequest(route, vary) + ? { requiresDynamicNavigationRequest: true } + : {}), + }; +} + +type PrefetchVaryAnalysis = { + canPrefetchRuntimeShell: boolean; + canPrefetchStaticRoute: boolean; + loadingShellParamNames: string[]; + prefetchParamNames: string[]; + prefetchVarySearchParams: boolean; + runtimePrefetchLoadingFallback: VinextRuntimePrefetchLoadingFallback | null; + runtimePrefetchParamNames: string[]; + runtimePrefetchVarySearchParams: boolean; + requiresDynamicNavigationRequest: boolean; +}; + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function readSource(filePath: string | null | undefined): string { + if (!filePath) return ""; + try { + return stripComments(fs.readFileSync(filePath, "utf8")); + } catch { + return ""; + } +} + +function sourceHasUseClientDirective(source: string): boolean { + return /^["']use client["']\s*;?/.test(source.trimStart()); +} + +function serverPrefetchSource(source: string): string { + return sourceHasUseClientDirective(source) ? "" : source; +} + +function readIdentifier(source: string, index: number): string | null { + const match = /[A-Za-z_$][\w$]*/.exec(source.slice(index)); + return match?.index === 0 ? match[0] : null; +} + +function skipsQuotedSource(source: string, index: number): number { + const quote = source[index]; + let cursor = index + 1; + while (cursor < source.length) { + const char = source[cursor]; + if (char === "\\") { + cursor += 2; + continue; + } + if (char === quote) return cursor + 1; + cursor++; + } + return source.length; +} + +function nextNonWhitespaceIndex(source: string, index: number): number { + let cursor = index; + while (cursor < source.length && /\s/.test(source[cursor] ?? "")) { + cursor++; + } + return cursor; +} + +function collectNextServerConnectionIdentifiers(source: string): Set { + const identifiers = new Set(); + const importPattern = /\bimport\s*\{([^}]+)\}\s*from\s*["']next\/server(?:\.js)?["']/g; + + for (const match of source.matchAll(importPattern)) { + for (const specifier of (match[1] ?? "").split(",")) { + const importName = specifier.trim().match(/^connection(?:\s+as\s+([A-Za-z_$][\w$]*))?$/); + if (importName) { + identifiers.add(importName[1] ?? "connection"); + } + } + } + + return identifiers; +} + +function findConnectionCallIndex(source: string): number | null { + const connectionIdentifiers = collectNextServerConnectionIdentifiers(source); + if (connectionIdentifiers.size === 0) return null; + + const allowedPreviousIdentifiers = new Set(["await", "return", "void", "yield"]); + const allowedPreviousPunctuation = new Set(["", "(", "{", "[", "=", ":", ",", ";", "?", "!"]); + let previousToken = ""; + let index = 0; + + while (index < source.length) { + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + index = skipsQuotedSource(source, index); + continue; + } + + const identifier = readIdentifier(source, index); + if (identifier !== null) { + if (connectionIdentifiers.has(identifier)) { + const nextIndex = nextNonWhitespaceIndex(source, index + identifier.length); + if ( + source[nextIndex] === "(" && + (allowedPreviousIdentifiers.has(previousToken) || + allowedPreviousPunctuation.has(previousToken)) + ) { + return index; + } + } + previousToken = identifier; + index += identifier.length; + continue; + } + + if (!/\s/.test(char ?? "")) { + previousToken = char ?? ""; + } + index++; + } + + return null; +} + +function staticPrefetchRegion(source: string): string { + const connectionIndex = findConnectionCallIndex(source); + return connectionIndex === null ? source : source.slice(0, connectionIndex); +} + +function findExportedFunctionBodyStart(source: string, functionName: string): number | null { + const startMatch = new RegExp( + String.raw`\bexport\s+(?:async\s+)?function\s+${escapeRegExp(functionName)}\b`, + ).exec(source); + if (!startMatch || startMatch.index === undefined) return null; + + const paramsStart = source.indexOf("(", startMatch.index); + if (paramsStart === -1) return null; + + let parenDepth = 0; + for (let index = paramsStart; index < source.length; index++) { + const char = source[index]; + if (char === "(") { + parenDepth++; + } else if (char === ")") { + parenDepth--; + if (parenDepth === 0) { + const bodyStart = source.indexOf("{", index); + return bodyStart === -1 ? null : bodyStart; + } + } + } + return null; +} + +function findExportedFunctionBodyEnd(source: string, functionName: string): number | null { + const bodyStart = findExportedFunctionBodyStart(source, functionName); + if (bodyStart === null) return null; + + let depth = 0; + for (let index = bodyStart; index < source.length; index++) { + const char = source[index]; + if (char === "{") { + depth++; + } else if (char === "}") { + depth--; + if (depth === 0) return index + 1; + } + } + return null; +} + +function extractExportedFunction(source: string, functionName: string): string { + const startMatch = new RegExp( + String.raw`\bexport\s+(?:async\s+)?function\s+${escapeRegExp(functionName)}\b`, + ).exec(source); + if (!startMatch || startMatch.index === undefined) return ""; + const end = findExportedFunctionBodyEnd(source, functionName); + return end === null ? "" : source.slice(startMatch.index, end); +} + +function removeExportedFunction(source: string, functionName: string): string { + const startMatch = new RegExp( + String.raw`\bexport\s+(?:async\s+)?function\s+${escapeRegExp(functionName)}\b`, + ).exec(source); + if (!startMatch || startMatch.index === undefined) return source; + const end = findExportedFunctionBodyEnd(source, functionName); + return end === null ? source : `${source.slice(0, startMatch.index)}\n${source.slice(end)}`; +} + +function collectPropAliases(source: string, propName: string): string[] { + return Array.from( + source.matchAll( + new RegExp(String.raw`\{[^}]*\b${escapeRegExp(propName)}\s*:\s*([A-Za-z_$][\w$]*)`, "g"), + ), + (match) => match[1], + ); +} + +function propMemberSourcePattern(propName: string): string { + return String.raw`\b[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*\.\s*${escapeRegExp(propName)}\b`; +} + +function hasPropMemberSource(source: string, propName: string): boolean { + return new RegExp(propMemberSourcePattern(propName)).test(source); +} + +function sourcePattern( + identifiers: readonly string[], + memberPropName: string, + source: string, +): string { + const patterns = identifiers.map((name) => String.raw`\b${escapeRegExp(name)}\b`); + if (hasPropMemberSource(source, memberPropName)) { + patterns.push(propMemberSourcePattern(memberPropName)); + } + return `(?:${patterns.join("|")})`; +} + +function collectSourceAliases( + source: string, + identifiers: readonly string[], + memberPropName: string, +): string[] { + const aliases: string[] = []; + const seen = new Set(identifiers); + + for (;;) { + const sourceExpressionPattern = sourcePattern(Array.from(seen), memberPropName, source); + const discovered = Array.from( + source.matchAll( + new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${sourceExpressionPattern}\b`, + "g", + ), + ), + (match) => match[1], + ).filter((alias) => !seen.has(alias)); + + if (discovered.length === 0) return aliases; + for (const alias of discovered) { + seen.add(alias); + aliases.push(alias); + } + } +} + +function collectParamAccesses(source: string, paramNames: readonly string[]): Set { + const region = staticPrefetchRegion(source); + const accessed = new Set(); + const paramPropAliases = collectPropAliases(region, "params"); + const paramPromiseIdentifiers = [ + "params", + ...paramPropAliases, + ...collectSourceAliases(region, ["params", ...paramPropAliases], "params"), + ]; + const paramPromiseSourcePattern = sourcePattern(paramPromiseIdentifiers, "params", region); + const awaitedParamAliases = Array.from( + region.matchAll( + new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+${paramPromiseSourcePattern}`, + "g", + ), + ), + (match) => match[1], + ); + const paramSourcePattern = sourcePattern( + [...paramPromiseIdentifiers, ...awaitedParamAliases], + "params", + region, + ); + const enumeratesParams = new RegExp( + String.raw`(?:\{\s*\.\.\.\s*${paramSourcePattern}\s*\}|\bObject\.(?:keys|values|entries)\s*\(\s*(?:await\s+)?${paramSourcePattern}|\bObject\.assign\s*\([^)]*(?:await\s+)?${paramSourcePattern})`, + ).test(region); + const computedParamAccess = new RegExp( + String.raw`(?:\b${paramSourcePattern}\s*\[|\bReflect\.get\s*\(\s*${paramSourcePattern}\b)`, + ).test(region); + const passesParamsToHelper = new RegExp( + String.raw`\b[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?\(\s*(?:await\s+${paramPromiseSourcePattern}|${paramSourcePattern})`, + ).test(region); + + // Unknown computed or helper reads must over-vary instead of sharing the wrong segment. + if (enumeratesParams || computedParamAccess || passesParamsToHelper) { + for (const name of paramNames) { + accessed.add(name); + } + } + + for (const name of paramNames) { + const escaped = escapeRegExp(name); + if ( + new RegExp( + String.raw`\{[^}]*\b${escaped}\b[^}]*\}\s*=\s*await\s+${paramPromiseSourcePattern}\b`, + ).test(region) + ) { + accessed.add(name); + continue; + } + if ( + new RegExp(String.raw`\{[^}]*\b${escaped}\b[^}]*\}\s*=\s*${paramSourcePattern}\b`).test( + region, + ) + ) { + accessed.add(name); + continue; + } + if ( + new RegExp( + String.raw`\(\s*await\s+${paramPromiseSourcePattern}\s*\)\s*\.\s*${escaped}\b`, + ).test(region) + ) { + accessed.add(name); + continue; + } + if (new RegExp(String.raw`\b${paramSourcePattern}\s*\.\s*${escaped}\b`).test(region)) { + accessed.add(name); + continue; + } + if (new RegExp(String.raw`["']${escaped}["']\s+in\s*${paramSourcePattern}\b`).test(region)) { + accessed.add(name); + continue; + } + if ( + awaitedParamAliases.some((alias) => + new RegExp(String.raw`\b${escapeRegExp(alias)}\s*\.\s*${escaped}\b`).test(region), + ) + ) { + accessed.add(name); + continue; + } + } + + return accessed; +} + +function collectRootParamAccesses(source: string, paramNames: readonly string[]): Set { + if (!/\bfrom\s+["']next\/root-params["']/.test(source)) return new Set(); + + const region = staticPrefetchRegion(source); + const accessed = new Set(); + for (const name of paramNames) { + if (new RegExp(String.raw`\b${escapeRegExp(name)}\s*\(`).test(region)) { + accessed.add(name); + } + } + return accessed; +} + +function mergeAccesses(target: Set, source: Set): void { + for (const name of source) { + target.add(name); + } +} + +function sourceAccessesSearchParams(source: string): boolean { + const region = staticPrefetchRegion(source); + const searchParamPropAliases = collectPropAliases(region, "searchParams"); + const searchParamPromiseIdentifiers = [ + "searchParams", + ...searchParamPropAliases, + ...collectSourceAliases(region, ["searchParams", ...searchParamPropAliases], "searchParams"), + ]; + const searchParamPromiseSourcePattern = sourcePattern( + searchParamPromiseIdentifiers, + "searchParams", + region, + ); + const awaitedSearchParamAliases = Array.from( + region.matchAll( + new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+${searchParamPromiseSourcePattern}`, + "g", + ), + ), + (match) => match[1], + ); + const searchParamSourcePattern = sourcePattern( + [...searchParamPromiseIdentifiers, ...awaitedSearchParamAliases], + "searchParams", + region, + ); + return ( + new RegExp(String.raw`\bawait\s+${searchParamPromiseSourcePattern}`).test(region) || + new RegExp(String.raw`\b${searchParamSourcePattern}\s*(?:\.|\[)`).test(region) || + new RegExp( + String.raw`\bObject\.(?:keys|values|entries)\s*\(\s*(?:await\s+)?${searchParamSourcePattern}`, + ).test(region) || + new RegExp(String.raw`\bObject\.assign\s*\([^)]*(?:await\s+)?${searchParamSourcePattern}`).test( + region, + ) || + new RegExp(String.raw`\{\s*\.\.\.\s*(?:await\s+)?${searchParamSourcePattern}\s*\}`).test( + region, + ) || + new RegExp(String.raw`\bReflect\.get\s*\(\s*${searchParamSourcePattern}\b`).test(region) || + new RegExp( + String.raw`\b[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?\(\s*(?:await\s+${searchParamPromiseSourcePattern}|${searchParamSourcePattern})\b`, + ).test(region) + ); +} + +function sourceAllowsRuntimePrefetch(source: string): boolean { + return ( + /\bexport\s+const\s+prefetch\s*=\s*["']allow-runtime["']/.test(source) || + /\bexport\s+const\s+unstable_instant\b[\s\S]*?\bprefetch\s*:\s*["']runtime["']/.test(source) + ); +} + +function sourceHasGenerateStaticParams(source: string): boolean { + return /\bexport\s+(?:async\s+)?function\s+generateStaticParams\b/.test(source); +} + +function sourceHasConnectionCall(source: string): boolean { + return findConnectionCallIndex(source) !== null; +} + +function sourceHasSuspenseFallback(source: string): boolean { + return /<\s*Suspense\b/.test(source); +} + +function decodeSimpleJsxText(value: string): string { + // Decode only one entity layer so `&lt;` remains `<`, matching JSX text semantics. + return value.replace(/&(quot|#39|amp|lt|gt);/g, (match, entity: string) => { + switch (entity) { + case "quot": + return '"'; + case "#39": + return "'"; + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + default: + return match; + } + }); +} + +function parseSimpleJsxAttributes(source: string): Record { + const attributes: Record = {}; + const attributePattern = + /\s([A-Za-z_:][\w:.-]*)(?:=(?:"([^"]*)"|'([^']*)'|\{\s*["']([^"']*)["']\s*\}))?/g; + for (const match of source.matchAll(attributePattern)) { + attributes[match[1]] = decodeSimpleJsxText(match[2] ?? match[3] ?? match[4] ?? "true"); + } + return attributes; +} + +function extractRuntimePrefetchLoadingFallback( + source: string, +): VinextRuntimePrefetchLoadingFallback | null { + const fallbackPattern = /fallback=\{\s*<([a-z][\w-]*)([^>]*)>\s*([^<{}]+?)\s*<\/\1>\s*\}/g; + const fallbacks: VinextRuntimePrefetchLoadingFallback[] = []; + for (const match of source.matchAll(fallbackPattern)) { + fallbacks.push({ + attributes: parseSimpleJsxAttributes(match[2] ?? ""), + tagName: match[1], + text: decodeSimpleJsxText((match[3] ?? "").replace(/\s+/g, " ").trim()), + }); + } + return ( + fallbacks.find((fallback) => Object.hasOwn(fallback.attributes, "data-loading")) ?? + fallbacks[0] ?? + null + ); +} + +function sortedKnownParams(input: Set, route: AppRoute): string[] { + return route.params.filter((name) => input.has(name)); +} + +function analyzePrefetchVary(route: AppRoute): PrefetchVaryAnalysis { + const layoutSources = route.layouts.map((layoutPath) => + serverPrefetchSource(readSource(layoutPath)), + ); + const pageSource = serverPrefetchSource(readSource(route.pagePath)); + const metadataSource = extractExportedFunction(pageSource, "generateMetadata"); + const pageBodySource = removeExportedFunction(pageSource, "generateMetadata"); + const pageDir = route.pagePath ? route.pagePath.replace(/[/\\][^/\\]+$/, "") : null; + let terminalLayoutSource = ""; + if (pageDir !== null) { + for (let index = route.layouts.length - 1; index >= 0; index--) { + const layoutPath = route.layouts[index]; + if (layoutPath?.replace(/[/\\][^/\\]+$/, "") === pageDir) { + terminalLayoutSource = layoutSources[index] ?? ""; + break; + } + } + } + const canPrefetchRuntimeShell = + sourceAllowsRuntimePrefetch(pageSource) || + (route.loadingPath === null && sourceHasSuspenseFallback(pageSource)); + const canPrefetchStaticRoute = + sourceHasGenerateStaticParams(pageSource) || + sourceHasGenerateStaticParams(terminalLayoutSource); + const requiresDynamicNavigationRequest = + route.isDynamic && canPrefetchStaticRoute && sourceHasConnectionCall(pageBodySource); + const layoutParamAccesses = new Set(); + const metadataParamAccesses = new Set(); + const pageParamAccesses = new Set(); + + for (const source of layoutSources) { + mergeAccesses(layoutParamAccesses, collectParamAccesses(source, route.params)); + mergeAccesses(layoutParamAccesses, collectRootParamAccesses(source, route.params)); + } + mergeAccesses(metadataParamAccesses, collectParamAccesses(metadataSource, route.params)); + mergeAccesses(metadataParamAccesses, collectRootParamAccesses(metadataSource, route.params)); + + if ( + !( + route.loadingPath === null && + sourceHasSuspenseFallback(pageSource) && + !sourceAllowsRuntimePrefetch(pageSource) + ) + ) { + mergeAccesses(pageParamAccesses, collectParamAccesses(pageBodySource, route.params)); + mergeAccesses(pageParamAccesses, collectRootParamAccesses(pageBodySource, route.params)); + } + + const loadingShellParamAccesses = new Set(layoutParamAccesses); + mergeAccesses(loadingShellParamAccesses, metadataParamAccesses); + const runtimeParamAccesses = new Set(pageParamAccesses); + mergeAccesses(runtimeParamAccesses, metadataParamAccesses); + + return { + canPrefetchRuntimeShell, + canPrefetchStaticRoute, + loadingShellParamNames: sortedKnownParams(loadingShellParamAccesses, route), + prefetchParamNames: sortedKnownParams( + canPrefetchRuntimeShell ? runtimeParamAccesses : pageParamAccesses, + route, + ), + prefetchVarySearchParams: + sourceAccessesSearchParams(pageBodySource) || layoutSources.some(sourceAccessesSearchParams), + runtimePrefetchLoadingFallback: canPrefetchRuntimeShell + ? extractRuntimePrefetchLoadingFallback(pageBodySource) + : null, + runtimePrefetchParamNames: sortedKnownParams(runtimeParamAccesses, route), + runtimePrefetchVarySearchParams: + sourceAccessesSearchParams(pageBodySource) || + sourceAccessesSearchParams(metadataSource) || + layoutSources.some(sourceAccessesSearchParams), + requiresDynamicNavigationRequest, }; } diff --git a/packages/vinext/src/plugins/rsc-reference-validation-compat.ts b/packages/vinext/src/plugins/rsc-reference-validation-compat.ts new file mode 100644 index 000000000..a4c5d34e0 --- /dev/null +++ b/packages/vinext/src/plugins/rsc-reference-validation-compat.ts @@ -0,0 +1,22 @@ +const DECODED_RSC_VIRTUAL_PREFIX = "/@id/\0virtual:vite-rsc/"; +const ENCODED_RSC_VIRTUAL_PREFIX = "/@id/__x00__virtual:vite-rsc/"; + +type ClientReferenceMetaLike = { + referenceKey: string; +}; + +function toEncodedViteRscVirtualReferenceKey(referenceId: string): string | null { + return referenceId.startsWith(DECODED_RSC_VIRTUAL_PREFIX) + ? ENCODED_RSC_VIRTUAL_PREFIX + referenceId.slice(DECODED_RSC_VIRTUAL_PREFIX.length) + : null; +} + +export function shouldAcceptDecodedViteRscReferenceValidation( + referenceId: string, + clientReferenceMetas: Iterable, +): boolean { + const encodedReferenceKey = toEncodedViteRscVirtualReferenceKey(referenceId); + if (encodedReferenceKey === null) return false; + + return Array.from(clientReferenceMetas).some((meta) => meta.referenceKey === encodedReferenceKey); +} diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index d913f80a5..a44139611 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -41,6 +41,7 @@ import { pushHistoryStateWithoutNotify, replaceClientParamsWithoutNotify, replaceHistoryStateWithoutNotify, + resolveAppPrefetchSharedCacheKey, resolvePrefetchCacheEntryMountedSlotsHeader, restoreRscResponse, saveScrollPosition, @@ -490,6 +491,11 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { const elements = await decodeAppElementsPromise( createFromFetch(Promise.resolve(restoreRscResponse(options.entry.snapshot))), ); + const preservePageElements = + options.entry.cacheForNavigation === false && options.entry.optimisticRouteShell !== true; + const variantKey = preservePageElements + ? (options.entry.runtimeTemplateVariantKey ?? options.entry.sharedCacheKey ?? null) + : null; const template = createOptimisticRouteTemplate({ allowLoadingShell: options.entry.optimisticRouteShell === true, basePath: __basePath, @@ -497,7 +503,10 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { href: options.entry.snapshot.url || source.rscUrl, interceptionContext: options.interceptionContext, mountedSlotsHeader: options.mountedSlotsHeader, + preservePageElements, routeManifest: options.routeManifest, + runtimeLoadingFallback: preservePageElements ? options.entry.runtimeLoadingFallback : null, + variantKey, }); if (template === null) return false; @@ -506,6 +515,7 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { interceptionContext: options.interceptionContext, mountedSlotsHeader: options.mountedSlotsHeader, routeId: template.routeId, + variantKey: template.variantKey, }), template, ); @@ -1805,6 +1815,13 @@ function bootstrapHydration( navigationKind, visitedResponse, }); + const sharedPrefetchCacheKey = resolveAppPrefetchSharedCacheKey(url.href, "navigation"); + const runtimeTemplateVariantKey = + sharedPrefetchCacheKey === null + ? null + : `${sharedPrefetchCacheKey}\0${ + resolveAppPrefetchSharedCacheKey(url.href, "loading-shell") ?? "" + }`; let routeManifest = navigationKind === "navigate" ? getBrowserRouteManifest() : null; const hasPrefetchCandidate = prefetchProbeDecision.kind === "probe" && @@ -1815,6 +1832,7 @@ function bootstrapHydration( { additionalRscUrls: additionalPrefetchRscUrls, notifyInvalidation: false, + sharedCacheKey: sharedPrefetchCacheKey, }, ); const reuseDecision = navigationPlanner.classifyNavigationReuse({ @@ -1931,6 +1949,7 @@ function bootstrapHydration( { additionalRscUrls: additionalPrefetchRscUrls, shouldConsume: () => browserNavigationController.isCurrentNavigation(navId), + sharedCacheKey: sharedPrefetchCacheKey, }, ); if (!browserNavigationController.isCurrentNavigation(navId)) return; @@ -1982,6 +2001,7 @@ function bootstrapHydration( mountedSlotsHeader, routeManifest, templates: optimisticRouteTemplates, + variantKey: runtimeTemplateVariantKey, }); if (optimisticPayload !== null) { diff --git a/packages/vinext/src/server/app-optimistic-routing.ts b/packages/vinext/src/server/app-optimistic-routing.ts index c1f04f5e6..054763252 100644 --- a/packages/vinext/src/server/app-optimistic-routing.ts +++ b/packages/vinext/src/server/app-optimistic-routing.ts @@ -1,4 +1,11 @@ -import { createElement, isValidElement, Suspense } from "react"; +import { + cloneElement, + createElement, + isValidElement, + Suspense, + type ReactElement, + 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"; @@ -11,6 +18,7 @@ import { type AppElementValue, type AppElements, } from "./app-elements.js"; +import type { VinextRuntimePrefetchLoadingFallback } from "../client/vinext-next-data.js"; type OptimisticRouteTrieNode = { catchAllChild: { paramName: string; route: RouteManifestRoute } | null; @@ -29,7 +37,10 @@ export type OptimisticRouteTemplate = { elements: AppElements; mountedSlotsHeader: string | null; pageElementIds: readonly string[]; + preservePageElements?: boolean; routeId: string; + runtimeLoadingFallback?: VinextRuntimePrefetchLoadingFallback | null; + variantKey?: string | null; }; type OptimisticNavigationPayload = { @@ -38,6 +49,12 @@ type OptimisticNavigationPayload = { template: OptimisticRouteTemplate; }; +type SuspenseReplacementResult = { + containsSuspense: boolean; + replaced: boolean; + value: AppElementValue; +}; + const routeTrieCache = new WeakMap(); // Shared never-settling thenable used to suspend optimistic page segments until // the real RSC payload replaces them. @@ -47,8 +64,9 @@ export function getOptimisticRouteTemplateKey(options: { interceptionContext: string | null; mountedSlotsHeader: string | null; routeId: string; + variantKey?: string | null; }): string { - return `${options.routeId}\0${options.interceptionContext ?? ""}\0${options.mountedSlotsHeader ?? ""}`; + return `${options.routeId}\0${options.interceptionContext ?? ""}\0${options.mountedSlotsHeader ?? ""}\0${options.variantKey ?? ""}`; } export function getOptimisticPrefetchSourceKey(options: { @@ -300,6 +318,100 @@ function OptimisticRouteSegment(): null { throw OPTIMISTIC_ROUTE_SEGMENT_SUSPENSE_TRIGGER; } +function createRuntimeLoadingFallbackElement( + fallback: VinextRuntimePrefetchLoadingFallback | null | undefined, +): AppElementValue | null { + if (!fallback) return null; + return createElement(fallback.tagName, fallback.attributes, fallback.text); +} + +function appendRuntimeLoadingFallback( + value: AppElementValue, + fallback: VinextRuntimePrefetchLoadingFallback | null | undefined, +): AppElementValue { + const fallbackElement = createRuntimeLoadingFallbackElement(fallback); + if (fallbackElement === null) return value; + + if (!isValidElement(value)) { + return [value as ReactNode, fallbackElement] as unknown as AppElementValue; + } + + const props = Reflect.get(value, "props"); + if (!isUnknownRecord(props)) return value; + + const children = Reflect.get(props, "children"); + const nextChildren = + children === undefined + ? fallbackElement + : Array.isArray(children) + ? [...children, fallbackElement] + : [children, fallbackElement]; + return cloneElement<{ children?: ReactNode }>( + value as ReactElement<{ children?: ReactNode }>, + undefined, + nextChildren as ReactNode, + ) as AppElementValue; +} + +function replaceInnermostSuspenseChildren( + value: AppElementValue, + depth = 0, +): SuspenseReplacementResult { + // Keep malformed or unexpectedly deep decoded trees from recursing forever. + if (depth > 100) return { containsSuspense: false, replaced: false, value }; + + if (Array.isArray(value)) { + let containsSuspense = false; + let replaced = false; + const nextValue = value.map((entry) => { + const result = replaceInnermostSuspenseChildren(entry, depth + 1); + containsSuspense ||= result.containsSuspense; + replaced ||= result.replaced; + return result.value; + }); + return { + containsSuspense, + replaced, + value: replaced ? nextValue : value, + }; + } + + if (!isValidElement(value)) return { containsSuspense: false, replaced: false, value }; + + const props = Reflect.get(value, "props"); + if (!isUnknownRecord(props)) return { containsSuspense: false, replaced: false, value }; + + const children = Reflect.get(props, "children") as AppElementValue; + const childResult = + children === undefined + ? { containsSuspense: false, replaced: false, value: children as AppElementValue } + : replaceInnermostSuspenseChildren(children, depth + 1); + const isSuspense = value.type === Suspense; + const containsSuspense = isSuspense || childResult.containsSuspense; + + if (isSuspense && !childResult.containsSuspense) { + return { + containsSuspense: true, + replaced: true, + value: cloneElement(value as ReactElement, undefined, createElement(OptimisticRouteSegment)), + }; + } + + if (childResult.replaced) { + return { + containsSuspense, + replaced: true, + value: cloneElement<{ children?: ReactNode }>( + value as ReactElement<{ children?: ReactNode }>, + undefined, + childResult.value as ReactNode, + ) as AppElementValue, + }; + } + + return { containsSuspense, replaced: false, value }; +} + export function createOptimisticRouteTemplate(options: { allowLoadingShell?: boolean; basePath: string; @@ -307,14 +419,21 @@ export function createOptimisticRouteTemplate(options: { href: string; interceptionContext: string | null; mountedSlotsHeader: string | null; + preservePageElements?: boolean; routeManifest: RouteManifest; + runtimeLoadingFallback?: VinextRuntimePrefetchLoadingFallback | null; + variantKey?: string | null; }): OptimisticRouteTemplate | null { const match = matchOptimisticRouteManifestRoute({ basePath: options.basePath, href: options.href, routeManifest: options.routeManifest, }); - if (match === null || (!options.allowLoadingShell && !match.route.isDynamic)) return null; + if ( + match === null || + (!options.preservePageElements && !options.allowLoadingShell && !match.route.isDynamic) + ) + return null; if (options.interceptionContext !== null) return null; const metadata = AppElementsWire.readMetadata(options.elements); @@ -325,7 +444,12 @@ export function createOptimisticRouteTemplate(options: { // are accepted only when the serialized route subtree still contains a // Suspense fallback. Authoritative loading-shell prefetches use the marker // check below instead. - if (!options.allowLoadingShell && !elementHasSuspenseFallback(routeElement)) return null; + if ( + !options.preservePageElements && + !options.allowLoadingShell && + !elementHasSuspenseFallback(routeElement) + ) + return null; if ( options.allowLoadingShell && options.elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY] !== "LoadingBoundary" @@ -344,14 +468,29 @@ export function createOptimisticRouteTemplate(options: { elements: options.elements, mountedSlotsHeader: options.mountedSlotsHeader, pageElementIds, + ...(options.preservePageElements ? { preservePageElements: true } : {}), routeId: match.route.id, + ...(options.runtimeLoadingFallback + ? { runtimeLoadingFallback: options.runtimeLoadingFallback } + : {}), + ...(options.variantKey != null ? { variantKey: options.variantKey } : {}), }; } export function createOptimisticRouteElements(template: OptimisticRouteTemplate): AppElements { const elements: Record = { ...template.elements }; for (const pageElementId of template.pageElementIds) { - elements[pageElementId] = createElement(OptimisticRouteSegment); + if (template.preservePageElements === true) { + const preserved = elements[pageElementId]; + if (preserved !== undefined) { + const result = replaceInnermostSuspenseChildren(preserved); + elements[pageElementId] = result.replaced + ? result.value + : appendRuntimeLoadingFallback(result.value, template.runtimeLoadingFallback); + } + } else { + elements[pageElementId] = createElement(OptimisticRouteSegment); + } } return elements; } @@ -363,6 +502,7 @@ export function resolveOptimisticNavigationPayload(options: { mountedSlotsHeader: string | null; routeManifest: RouteManifest; templates: ReadonlyMap; + variantKey?: string | null; }): OptimisticNavigationPayload | null { if (options.interceptionContext !== null) return null; @@ -376,13 +516,26 @@ export function resolveOptimisticNavigationPayload(options: { }); if (match === null) return null; - const template = options.templates.get( - getOptimisticRouteTemplateKey({ - interceptionContext: options.interceptionContext, - mountedSlotsHeader: options.mountedSlotsHeader, - routeId: match.route.id, - }), - ); + const variantTemplate = + options.variantKey == null + ? undefined + : options.templates.get( + getOptimisticRouteTemplateKey({ + interceptionContext: options.interceptionContext, + mountedSlotsHeader: options.mountedSlotsHeader, + routeId: match.route.id, + variantKey: options.variantKey, + }), + ); + const template = + variantTemplate ?? + 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-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 1b27960d7..9bb8d528d 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -1,7 +1,7 @@ import { Fragment, createElement, type ComponentType, type ReactNode } from "react"; import { buildClientHookErrorMessage } from "vinext/shims/client-hook-error"; -import DefaultGlobalError from "vinext/shims/default-global-error"; import { + DefaultGlobalError, ErrorBoundary, GlobalErrorBoundary, SerializedErrorBoundary, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index c5896ae33..5defcaa9b 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -15,9 +15,9 @@ import { NotFoundBoundary, RedirectBoundary, UnauthorizedBoundary, + DefaultGlobalError, } from "vinext/shims/error-boundary"; import { AppRouterScrollTarget } from "vinext/shims/app-router-scroll"; -import DefaultGlobalError from "vinext/shims/default-global-error"; import type { AppRouteSemanticIds } from "../routing/app-route-graph.js"; import { LayoutSegmentProvider } from "vinext/shims/layout-segment-context"; import { diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index d594f11d9..61e47ddbd 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -52,7 +52,7 @@ import { AppRouterContext } from "vinext/shims/internal/app-router-context"; import { createClientReferencePreloader } from "./app-client-reference-preloader.js"; import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js"; import { isPprFallbackShellAbortError } from "vinext/shims/ppr-fallback-shell"; -import DefaultGlobalError from "vinext/shims/default-global-error"; +import { DefaultGlobalErrorDocument } from "./default-global-error-document.js"; import { appendAssetDeploymentIdQuery } from "../utils/deployment-id.js"; import { ssrAppRouterInstance } from "./app-ssr-router-instance.js"; // @ts-expect-error — resolved by the vinext build plugin in SSR environments. @@ -180,7 +180,7 @@ function renderSsrErrorDocumentShell( nonce?: string, ): ReadableStream { const html = renderToStaticMarkup( - createReactElement(DefaultGlobalError, { + createReactElement(DefaultGlobalErrorDocument, { error: null, }), ).replace("