From 59376b9f808d3bf5889df1831022014265170057 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 11:01:22 +0100 Subject: [PATCH 1/6] fix(app-router): vary segment prefetches by accessed params --- .../vinext/src/client/vinext-next-data.ts | 14 + .../vinext/src/entries/app-browser-entry.ts | 405 +++++++++++++++++- packages/vinext/src/index.ts | 2 + .../rsc-reference-validation-compat.ts | 91 ++++ .../vinext/src/server/app-browser-entry.ts | 24 +- .../src/server/app-optimistic-routing.ts | 177 +++++++- .../src/server/app-page-boundary-render.ts | 2 +- .../src/server/app-page-route-wiring.tsx | 2 +- packages/vinext/src/server/app-ssr-entry.ts | 4 +- .../server/default-global-error-document.tsx | 182 ++++++++ .../src/server/default-global-error-module.ts | 2 +- packages/vinext/src/shims/error-boundary.tsx | 8 +- packages/vinext/src/shims/link.tsx | 132 ++++-- packages/vinext/src/shims/navigation.ts | 93 +++- tests/app-optimistic-routing.test.ts | 126 +++++- tests/app-prefetch-vary-analysis.test.ts | 395 +++++++++++++++++ .../nextjs-compat/vary-params.browser.spec.ts | 269 ++++++++++++ tests/entry-templates.test.ts | 4 +- .../link-accordion.tsx | 20 + .../search-params/page.tsx | 33 ++ .../search-params/static-target/page.tsx | 9 + .../search-params/target-page/page.tsx | 19 + tests/link.test.ts | 23 +- tests/rsc-reference-validation-compat.test.ts | 60 +++ 24 files changed, 2027 insertions(+), 69 deletions(-) create mode 100644 packages/vinext/src/plugins/rsc-reference-validation-compat.ts create mode 100644 packages/vinext/src/server/default-global-error-document.tsx create mode 100644 tests/app-prefetch-vary-analysis.test.ts create mode 100644 tests/e2e/app-router/nextjs-compat/vary-params.browser.spec.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/segment-cache-vary-params/link-accordion.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/segment-cache-vary-params/search-params/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/segment-cache-vary-params/search-params/static-target/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/segment-cache-vary-params/search-params/target-page/page.tsx create mode 100644 tests/rsc-reference-validation-compat.test.ts 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..9c7ee6e57 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,413 @@ 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 staticPrefetchRegion(source: string): string { + const connectionMatch = /\bconnection\s*\(/.exec(source); + return connectionMatch?.index === undefined ? source : source.slice(0, connectionMatch.index); +} + +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 collectParamAccesses(source: string, paramNames: readonly string[]): Set { + const region = staticPrefetchRegion(source); + const accessed = new Set(); + const paramPropAliases = collectPropAliases(region, "params"); + const paramPromiseSources = ["params", ...paramPropAliases].map(escapeRegExp); + const paramPromiseSourcePattern = `(?:${paramPromiseSources.join("|")})`; + const awaitedParamAliases = Array.from( + region.matchAll( + new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+${paramPromiseSourcePattern}\b`, + "g", + ), + ), + (match) => match[1], + ); + const paramSources = ["params", ...paramPropAliases, ...awaitedParamAliases].map(escapeRegExp); + const paramSourcePattern = `(?:${paramSources.join("|")})`; + const enumeratesParams = new RegExp( + String.raw`(?:\{\s*\.\.\.\s*${paramSourcePattern}\s*\}|\bObject\.(?:keys|values|entries|assign)\s*\(\s*${paramSourcePattern}\b)`, + ).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+params|${paramSourcePattern})\b`, + ).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${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 searchParamPromiseSources = ["searchParams", ...searchParamPropAliases].map(escapeRegExp); + const searchParamPromiseSourcePattern = `(?:${searchParamPromiseSources.join("|")})`; + const awaitedSearchParamAliases = Array.from( + region.matchAll( + new RegExp( + String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+${searchParamPromiseSourcePattern}\b`, + "g", + ), + ), + (match) => match[1], + ); + const searchParamSources = [ + "searchParams", + ...searchParamPropAliases, + ...awaitedSearchParamAliases, + ].map(escapeRegExp); + const searchParamSourcePattern = `(?:${searchParamSources.join("|")})`; + return ( + new RegExp(String.raw`\bawait\s+${searchParamPromiseSourcePattern}\b`).test(region) || + new RegExp(String.raw`\b${searchParamSourcePattern}\s*(?:\.|\[)`).test(region) || + new RegExp( + String.raw`\bObject\.(?:keys|values|entries|assign)\s*\(\s*(?:await\s+)?${searchParamSourcePattern}\b`, + ).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 /\bconnection\s*\(/.test(source); +} + +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/index.ts b/packages/vinext/src/index.ts index 3d9dfb12d..3202f02fb 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -137,6 +137,7 @@ import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js" import { dataUrlCssPlugin } from "./plugins/css-data-url.js"; import { createCssModuleImportCompatibilityPlugin } from "./plugins/css-module-imports.js"; import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-reference-loaders.js"; +import { createRscReferenceValidationCompatPlugin } from "./plugins/rsc-reference-validation-compat.js"; import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js"; import { createStyledJsxPlugin } from "./plugins/styled-jsx.js"; import { @@ -6213,6 +6214,7 @@ export const loadServerActionClient = ${ // Append auto-injected RSC plugins if applicable if (rscPluginPromise) { plugins.push(rscPluginPromise); + plugins.push(createRscReferenceValidationCompatPlugin()); plugins.push(createRscClientReferenceLoadersPlugin()); } 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..312eb2315 --- /dev/null +++ b/packages/vinext/src/plugins/rsc-reference-validation-compat.ts @@ -0,0 +1,91 @@ +import type { Plugin } from "vite"; +import type { PluginApi } from "@vitejs/plugin-rsc"; + +const REFERENCE_VALIDATION_PREFIX = "\0virtual:vite-rsc/reference-validation?"; +// oxlint-disable-next-line no-control-regex -- null byte prefix is intentional (Vite virtual module convention) +const REFERENCE_VALIDATION_FILTER = /^\0virtual:vite-rsc\/reference-validation\?/; +const DECODED_RSC_VIRTUAL_PREFIX = "/@id/\0virtual:vite-rsc/"; +const ENCODED_RSC_VIRTUAL_PREFIX = "/@id/__x00__virtual:vite-rsc/"; + +type RscPluginWithApi = Plugin & { + api?: PluginApi; +}; + +type ClientReferenceMetaLike = { + referenceKey: string; +}; + +type ReferenceValidationRequest = { + type: string; + id: string; +}; + +function parseReferenceValidationRequest(id: string): ReferenceValidationRequest | null { + if (!id.startsWith(REFERENCE_VALIDATION_PREFIX)) return null; + const queryIndex = id.indexOf("?"); + if (queryIndex === -1) return null; + + const query = new URLSearchParams(id.slice(queryIndex + 1)); + const type = query.get("type"); + const referenceId = query.get("id"); + return type && referenceId ? { type, id: referenceId } : null; +} + +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); +} + +export function createRscReferenceValidationCompatPlugin(): Plugin { + let rscApi: PluginApi | undefined; + + return { + name: "vinext:rsc-reference-validation-compat", + enforce: "pre", + apply: "serve", + configResolved(config) { + rscApi = ( + config.plugins.find((plugin) => plugin.name === "rsc:minimal") as + | RscPluginWithApi + | undefined + )?.api; + }, + load: { + filter: { id: REFERENCE_VALIDATION_FILTER }, + handler(id) { + const request = parseReferenceValidationRequest(id); + if (request?.type !== "client") return null; + + const manager = rscApi?.manager; + if (!manager) return null; + + // @vitejs/plugin-rsc records dev virtual reference keys after Vite + // import-analysis escapes the null byte as `__x00__`, while React's + // SSR validation request can arrive with the decoded null byte. Accept + // only when the normalized key is already present in plugin-rsc's + // metadata, preserving the validator's allowlist semantics. + if ( + shouldAcceptDecodedViteRscReferenceValidation( + request.id, + Object.values(manager.clientReferenceMetaMap), + ) + ) { + return "export {};"; + } + + return null; + }, + }, + }; +} diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3b0310f17..05e9c9f1f 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -40,6 +40,7 @@ import { pushHistoryStateWithoutNotify, replaceClientParamsWithoutNotify, replaceHistoryStateWithoutNotify, + resolveAppPrefetchSharedCacheKey, resolvePrefetchCacheEntryMountedSlotsHeader, restoreRscResponse, saveScrollPosition, @@ -132,11 +133,11 @@ import { restoreSynchronousPopstateScrollPosition, } from "./app-browser-popstate.js"; import { + DefaultGlobalError, DevRecoveryBoundary, GlobalErrorBoundary, RedirectBoundary, } from "vinext/shims/error-boundary"; -import DefaultGlobalError from "vinext/shims/default-global-error"; import { AppRouterContext } from "vinext/shims/internal/app-router-context"; import { BfcacheStateKeyMapContext, ElementsContext, Slot } from "vinext/shims/slot"; import type { RouteManifest } from "../routing/app-route-graph.js"; @@ -464,6 +465,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, @@ -471,7 +477,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; @@ -480,6 +489,7 @@ async function learnOptimisticRouteTemplateFromPrefetch(options: { interceptionContext: options.interceptionContext, mountedSlotsHeader: options.mountedSlotsHeader, routeId: template.routeId, + variantKey: template.variantKey, }), template, ); @@ -1760,6 +1770,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" && @@ -1767,7 +1784,7 @@ function bootstrapHydration( rscUrl, requestInterceptionContext, mountedSlotsHeader, - { notifyInvalidation: false }, + { notifyInvalidation: false, sharedCacheKey: sharedPrefetchCacheKey }, ); const reuseDecision = navigationPlanner.classifyNavigationReuse({ bypassNavigationCache: shouldBypassNavigationCache, @@ -1882,6 +1899,7 @@ function bootstrapHydration( mountedSlotsHeader, { shouldConsume: () => browserNavigationController.isCurrentNavigation(navId), + sharedCacheKey: sharedPrefetchCacheKey, }, ); if (!browserNavigationController.isCurrentNavigation(navId)) return; @@ -1928,8 +1946,8 @@ function bootstrapHydration( mountedSlotsHeader, routeManifest, templates: optimisticRouteTemplates, + variantKey: runtimeTemplateVariantKey, }); - if (optimisticPayload !== null) { detachedNavigationCommits = true; const optimisticNavigationSnapshot = createClientNavigationRenderSnapshot( 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("