diff --git a/packages/vinext/package.json b/packages/vinext/package.json index be5c1929fb..9062847cd0 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -198,7 +198,7 @@ "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", - "@vitejs/plugin-rsc": "^0.5.26", + "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 588222ff26..0ad21ca7c3 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -167,6 +167,7 @@ import { validateMiddlewareModuleExports } from "./plugins/middleware-export-val import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { createDynamicPreloadMetadataPlugin } from "./plugins/dynamic-preload-metadata.js"; import { createOgInlineFetchAssetsPlugin, createOgAssetsPlugin } from "./plugins/og-assets.js"; +import { createUseCacheCallablePlugin } from "./plugins/use-cache-callable.js"; import { generateRouteTypes } from "./typegen.js"; import { mergeOptimizeDepsExclude, @@ -301,108 +302,6 @@ installSocketErrorBackstop(); type ASTNode = ReturnType["body"][number]["parent"]; -type UseCacheAstNode = Record & { type: string }; - -function isUseCacheAstNode(value: unknown): value is UseCacheAstNode { - return ( - typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" - ); -} - -function hasInlineUseCacheDirective(node: UseCacheAstNode): boolean { - const body = - isUseCacheAstNode(node.body) && node.body.type === "BlockStatement" ? node.body : null; - if (!body || !Array.isArray(body.body)) return false; - - return body.body.some((statement) => { - if (!isUseCacheAstNode(statement) || statement.type !== "ExpressionStatement") return false; - const expression = isUseCacheAstNode(statement.expression) ? statement.expression : null; - return ( - expression?.type === "Literal" && - typeof expression.value === "string" && - /^use cache(:\s*\w+)?$/.test(expression.value) - ); - }); -} - -function functionAcceptsSecondArgument(node: UseCacheAstNode): boolean { - const params = Array.isArray(node.params) ? node.params : []; - if (params.length >= 2) return true; - return params.some((param) => isUseCacheAstNode(param) && param.type === "RestElement"); -} - -function collectInlineUseCacheSecondArgumentUsage(value: unknown): boolean[] { - const usage: boolean[] = []; - - function walk(node: unknown): void { - if (Array.isArray(node)) { - for (const child of node) walk(child); - return; - } - if (!isUseCacheAstNode(node)) return; - - if ( - (node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression") && - hasInlineUseCacheDirective(node) - ) { - usage.push(functionAcceptsSecondArgument(node)); - } - - for (const [key, child] of Object.entries(node)) { - if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "parent") { - continue; - } - walk(child); - } - } - - walk(value); - return usage; -} - -function collectFileUseCacheSecondArgumentUsage(body: unknown[]): Map { - const localFunctions = new Map(); - - function collectDeclaration(value: unknown): void { - if (!isUseCacheAstNode(value)) return; - if (value.type === "FunctionDeclaration") { - const id = isUseCacheAstNode(value.id) ? value.id : null; - if (id?.type === "Identifier" && typeof id.name === "string") { - localFunctions.set(id.name, functionAcceptsSecondArgument(value)); - } - return; - } - if (value.type !== "VariableDeclaration" || !Array.isArray(value.declarations)) return; - - for (const declaration of value.declarations) { - if (!isUseCacheAstNode(declaration) || declaration.type !== "VariableDeclarator") continue; - const id = isUseCacheAstNode(declaration.id) ? declaration.id : null; - const init = isUseCacheAstNode(declaration.init) ? declaration.init : null; - if ( - id?.type === "Identifier" && - typeof id.name === "string" && - init && - (init.type === "FunctionExpression" || init.type === "ArrowFunctionExpression") - ) { - localFunctions.set(id.name, functionAcceptsSecondArgument(init)); - } - } - } - - for (const statement of body) { - if (!isUseCacheAstNode(statement)) continue; - collectDeclaration( - statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" - ? statement.declaration - : statement, - ); - } - - return localFunctions; -} - function isInsideDirectory(dir: string, filePath: string): boolean { const relativePath = path.relative(dir, filePath); return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); @@ -1655,26 +1554,22 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // different class identities. Resolving from the project root ensures a // single shared vite instance. // - // Pre-resolve both the main plugin and the /transforms subpath eagerly - // so all import() calls in this module use consistent resolution. + // Pre-resolve the plugins eagerly so all import() calls in this module use + // consistent resolution. let resolvedReactPath: string | null = null; let resolvedRscPath: string | null = null; - let resolvedRscTransformsPath: string | null = null; let rscPluginModulePromise: Promise | null = null; // Prefer the user's project graph so vinext shares the app's Vite/plugin // instances. In source/workspace development, test fixtures may not declare // peer deps explicitly, so fall back to vinext's own install location. resolvedReactPath = resolveOptionalDependency(earlyBaseDir, "@vitejs/plugin-react"); resolvedRscPath = resolveOptionalDependency(earlyBaseDir, "@vitejs/plugin-rsc"); - resolvedRscTransformsPath = resolveOptionalDependency( - earlyBaseDir, - "@vitejs/plugin-rsc/transforms", - ); // If app/ exists and auto-RSC is enabled, create a lazy Promise that // resolves to the configured RSC plugin array. Vite's asyncFlatten // will resolve this before processing the plugin list. let rscPluginPromise: Promise | null = null; + let manualUseCachePluginPromise: Promise | null = null; if (earlyAppDirExists && autoRsc) { if (!resolvedRscPath) { throw new Error( @@ -1687,21 +1582,43 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const rscImport = import(pathToFileURL(resolvedRscPath).href); rscPluginModulePromise = rscImport; rscPluginPromise = rscImport - .then((mod) => { + .then(async (mod) => { const rsc = mod.default; - return rsc({ + const plugins: Plugin[] = rsc({ entries: { rsc: VIRTUAL_RSC_ENTRY, ssr: VIRTUAL_APP_SSR_ENTRY, client: VIRTUAL_APP_BROWSER_ENTRY, }, }); + const useCachePlugin = await createUseCacheCallablePlugin({ + projectRoot: earlyBaseDir, + cacheRuntime: pathToFileURL(resolveShimModulePath(shimsDir, "cache-callable-runtime")) + .href, + getAppDir: () => appDir, + matchesPageExtension: (fileName) => fileMatcher.extensionRegex.test(fileName), + }); + const useServerIndex = plugins.findIndex((plugin) => plugin.name === "rsc:use-server"); + if (useServerIndex === -1) { + throw new Error("vinext: Failed to locate @vitejs/plugin-rsc use-server plugin."); + } + plugins.splice(useServerIndex, 0, useCachePlugin); + return plugins; }) .catch((cause) => { throw new Error("vinext: Failed to load @vitejs/plugin-rsc.", { cause, }); }); + } else if (earlyAppDirExists && resolvedRscPath) { + rscPluginModulePromise = import(pathToFileURL(resolvedRscPath).href); + manualUseCachePluginPromise = createUseCacheCallablePlugin({ + projectRoot: earlyBaseDir, + cacheRuntime: pathToFileURL(resolveShimModulePath(shimsDir, "cache-callable-runtime")).href, + getAppDir: () => appDir, + matchesPageExtension: (fileName) => fileMatcher.extensionRegex.test(fileName), + allowMissingRsc: true, + }); } async function resolveHasServerActions( @@ -6358,189 +6275,6 @@ export const loadServerActionClient = ${ // IDs through Vite's build manifest so it can emit boundary-scoped preload // hints with the request CSP nonce. createDynamicPreloadMetadataPlugin(), - // "use cache" directive transform: - // Detects "use cache" at file-level or function-level and wraps the - // exports/functions with registerCachedFunction() from vinext/cache-runtime. - // Runs without enforce so it executes after JSX transform (parseAst needs plain JS). - { - name: "vinext:use-cache", - - transform: { - // Hook filter: only invoke JS when code contains 'use cache'. - // The vast majority of files don't use this directive. - filter: { - id: { - include: /\.(tsx?|jsx?|mjs)$/, - exclude: [/node_modules/, VIRTUAL_MODULE_ID_RE], - }, - code: "use cache", - }, - async handler(code, id) { - // Parse the AST first to check for actual "use cache" directives before - // throwing the missing-RSC error. The code filter can - // fire on files that contain "use cache" only in comments or string - // literals (e.g., in error messages), not as real directives. - const ast = parseAst(code); - - // Check for file-level "use cache" directive - const cacheDirective = ast.body.find( - (node) => - node.type === "ExpressionStatement" && - node.expression?.type === "Literal" && - typeof node.expression.value === "string" && - node.expression.value.startsWith("use cache"), - ); - - // Keep the declaration shape because Function.length drops default and - // rest parameters. Next.js's cache transform likewise records declared - // arguments so metadata resolution can decide whether to pass `parent`. - const inlineCacheSecondArgumentUsage = cacheDirective - ? [] - : collectInlineUseCacheSecondArgumentUsage(ast.body); - const hasInlineCache = inlineCacheSecondArgumentUsage.length > 0; - - if (!cacheDirective && !hasInlineCache) return null; - - if (!resolvedRscTransformsPath) { - throw new Error( - "vinext: 'use cache' requires @vitejs/plugin-rsc to be installed.\n" + - "Run: " + - detectPackageManager(process.cwd()) + - " @vitejs/plugin-rsc", - ); - } - const { transformWrapExport, transformHoistInlineDirective } = await import( - pathToFileURL(resolvedRscTransformsPath).href - ); - - if (cacheDirective) { - // File-level "use cache" — wrap function exports with - // registerCachedFunction. Page default exports are wrapped directly - // (they're leaf components). Layout/template defaults are excluded - // because they receive {children} from the framework. - // oxlint-disable-next-line typescript/no-explicit-any - const directiveValue = (cacheDirective as any).expression.value; - const variant = - directiveValue === "use cache" ? "" : directiveValue.replace("use cache:", "").trim(); - - // Only skip default export wrapping for layouts and templates — - // they receive {children} from the framework which requires - // temporary reference handling that registerCachedFunction doesn't - // support yet. Pages, not-found, loading, error, and default are - // leaf components with no {children} prop and can be cached directly. - const isLayoutOrTemplate = /\/(layout|template)\.(tsx?|jsx?|mjs)$/.test(id); - const modulePath = stripViteModuleQuery(id); - const moduleFileName = path.basename(modulePath); - const isAppPageModule = - hasAppDir && - isInsideDirectory(appDir, modulePath) && - path.parse(moduleFileName).name === "page" && - fileMatcher.extensionRegex.test(moduleFileName); - - const runtimeModuleUrl = pathToFileURL( - resolveShimModulePath(shimsDir, "cache-runtime"), - ).href; - const secondArgumentUsage = collectFileUseCacheSecondArgumentUsage(ast.body); - const result = transformWrapExport(code, ast, { - runtime: (value: string, name: string) => { - const runtimeOptions: string[] = []; - // A local declaration gives us its exact parameter shape. For - // opaque exports (for example `export { fn } from "./impl"`), - // match Next.js's unknown-signature behavior and conservatively - // treat every argument as used. - const acceptsSecondArgument = secondArgumentUsage.get(value) ?? true; - runtimeOptions.push(`acceptsSecondArgument: ${acceptsSecondArgument}`); - if (name === "default" && isAppPageModule) { - runtimeOptions.push("appPageDefaultExport: true"); - } - const optionsArgument = - runtimeOptions.length > 0 ? `, { ${runtimeOptions.join(", ")} }` : ""; - return `(await import(${JSON.stringify(runtimeModuleUrl)})).registerCachedFunction(${value}, ${JSON.stringify(id + ":" + name)}, ${JSON.stringify(variant)}${optionsArgument})`; - }, - rejectNonAsyncFunction: false, - filter: (name: string, meta: { isFunction?: boolean }) => { - // Skip non-functions (constants, types, etc.) - if (meta.isFunction === false) return false; - // Skip the default export on layout/template files — these - // receive {children} from the framework, and caching them - // requires temporary reference handling for the children slot. - // Named exports (e.g. generateMetadata) are still wrapped. - if (isLayoutOrTemplate && name === "default") return false; - return true; - }, - }); - - if (result.exportNames.length > 0) { - // Remove the directive itself so it doesn't cause runtime errors - const output = result.output; - output.overwrite( - cacheDirective.start, - cacheDirective.end, - `/* "use cache" — wrapped by vinext */`, - ); - return { - code: output.toString(), - map: output.generateMap({ hires: "boundary" }), - }; - } - - // Even if no exports were wrapped, still strip the directive - // (e.g., layout/template file with only a default export) - const output = new MagicString(code); - output.overwrite( - cacheDirective.start, - cacheDirective.end, - `/* "use cache" — handled by vinext */`, - ); - return { - code: output.toString(), - map: output.generateMap({ hires: "boundary" }), - }; - } - - // Check for function-level "use cache" directives - // (e.g., async function getData() { "use cache"; ... }) - if (hasInlineCache) { - const runtimeModuleUrl2 = pathToFileURL( - resolveShimModulePath(shimsDir, "cache-runtime"), - ).href; - - try { - let transformedFunctionIndex = 0; - const result = transformHoistInlineDirective(code, ast, { - directive: /^use cache(:\s*\w+)?$/, - runtime: (value: string, name: string, meta: { directiveMatch: string[] }) => { - const directiveMatch = meta.directiveMatch[0]; - const variant = - directiveMatch === "use cache" - ? "" - : directiveMatch.replace("use cache:", "").trim(); - const acceptsSecondArgument = - inlineCacheSecondArgumentUsage[transformedFunctionIndex++]; - const optionsArgument = - acceptsSecondArgument === undefined - ? "" - : `, { acceptsSecondArgument: ${acceptsSecondArgument} }`; - return `(await import(${JSON.stringify(runtimeModuleUrl2)})).registerCachedFunction(${value}, ${JSON.stringify(id + ":" + name)}, ${JSON.stringify(variant)}${optionsArgument})`; - }, - rejectNonAsyncFunction: false, - }); - - if (result.names.length > 0) { - return { - code: result.output.toString(), - map: result.output.generateMap({ hires: "boundary" }), - }; - } - } catch { - // If hoisting fails (e.g., complex closure), fall through - } - } - - return null; - }, - }, - }, createImportMetaUrlPlugin({ getRoot: () => root, }), @@ -7132,6 +6866,8 @@ export const loadServerActionClient = ${ plugins.push(rscPluginPromise); plugins.push(createRscReferenceValidationNormalizerPlugin()); plugins.push(createRscClientReferenceLoadersPlugin()); + } else if (manualUseCachePluginPromise) { + plugins.push(manualUseCachePluginPromise); } return plugins; diff --git a/packages/vinext/src/plugins/use-cache-callable.ts b/packages/vinext/src/plugins/use-cache-callable.ts new file mode 100644 index 0000000000..bea55e69d4 --- /dev/null +++ b/packages/vinext/src/plugins/use-cache-callable.ts @@ -0,0 +1,374 @@ +import { createRequire } from "node:module"; +import path from "pathslash"; +import { pathToFileURL } from "node:url"; +import type { RscPluginManager } from "@vitejs/plugin-rsc"; +import type { + ModuleExportMeta, + TransformHoistInlineDirectiveMeta, +} from "@vitejs/plugin-rsc/transforms"; +import { parseAstAsync, type Plugin } from "vite"; +import { stripViteModuleQuery } from "../utils/path.js"; + +type RscTransforms = typeof import("@vitejs/plugin-rsc/transforms"); +type Program = Awaited>; + +type Options = { + projectRoot: string; + cacheRuntime: string; + getAppDir: () => string | undefined; + matchesPageExtension: (fileName: string) => boolean; + allowMissingRsc?: boolean; +}; + +type CacheWrapperOptions = { + acceptsSecondArgument: boolean; + appPageDefaultExport?: boolean; + argumentCount?: number; +}; + +const PLUGIN_NAME = "vinext:server-function-directives"; +const SOURCE_MODULE_ID_RE = /\.(?:tsx?|jsx?|mjs)(?:\?.*)?$/; +const DEPENDENCY_MODULE_ID_RE = /[\\/]node_modules[\\/]/; +const RESOLVED_VIRTUAL_MODULE_ID_RE = new RegExp(`^${String.fromCharCode(0)}`); +const USE_CACHE_DIRECTIVE = /^use cache(?:: ([^\s].*))?$/; +const USE_CACHE_DIRECTIVE_CANDIDATE = /^use cache.*$/; + +function resolvePluginRscModule(projectRoot: string, specifier: string): string { + try { + return createRequire(path.join(projectRoot, "package.json")).resolve(specifier); + } catch {} + + try { + return createRequire(import.meta.url).resolve(specifier); + } catch { + throw new Error(`vinext: Installed @vitejs/plugin-rsc does not expose ${specifier}.`); + } +} + +function matchUseCacheDirective(directive: string): RegExpMatchArray { + const match = directive.match(USE_CACHE_DIRECTIVE); + if (match) return match; + + const cacheKind = directive.includes(":") + ? directive.slice(directive.indexOf(":") + 1).trim() + : directive.slice("use cache".length).trim(); + const expected = cacheKind ? `use cache: ${cacheKind}` : "use cache"; + throw new Error( + `Invalid cache directive ${JSON.stringify(directive)}. Did you mean ${JSON.stringify(expected)}?`, + ); +} + +function findModuleUseCacheDirective(ast: Program): string | undefined { + for (const statement of ast.body) { + if ( + statement.type !== "ExpressionStatement" || + !("directive" in statement) || + typeof statement.directive !== "string" + ) { + break; + } + if (statement.directive.startsWith("use cache")) { + return matchUseCacheDirective(statement.directive)[0]; + } + } +} + +function getArgumentCount( + meta: Pick | TransformHoistInlineDirectiveMeta, +): number | undefined { + const node = meta.valueNode; + if ( + node?.type !== "FunctionDeclaration" && + node?.type !== "FunctionExpression" && + node?.type !== "ArrowFunctionExpression" + ) { + return; + } + return node.params.at(-1)?.type === "RestElement" ? undefined : node.params.length; +} + +function acceptsSecondArgument( + meta: Pick | TransformHoistInlineDirectiveMeta, +): boolean { + const node = meta.valueNode; + if ( + node?.type !== "FunctionDeclaration" && + node?.type !== "FunctionExpression" && + node?.type !== "ArrowFunctionExpression" + ) { + return true; + } + return ( + node.params.length >= 2 || node.params.some((parameter) => parameter.type === "RestElement") + ); +} + +function isInsideDirectory(directory: string, filePath: string): boolean { + const relativePath = path.relative(directory, filePath); + return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +} + +function isAppPageDefaultExport( + options: Options, + id: string, + name: string, + isModuleDirective: boolean, +): boolean { + const appDir = options.getAppDir(); + if (!isModuleDirective || name !== "default" || !appDir) return false; + const modulePath = stripViteModuleQuery(id); + const moduleFileName = path.basename(modulePath); + return ( + isInsideDirectory(appDir, modulePath) && + path.parse(moduleFileName).name === "page" && + options.matchesPageExtension(moduleFileName) + ); +} + +function shouldTransformModuleExport(name: string, id: string, meta: ModuleExportMeta): boolean { + if ( + meta.isFunction === false && + (meta.valueNode?.type === "ObjectExpression" || meta.valueNode?.type === "ArrayExpression") + ) { + return false; + } + if (/\/(layout|template)\.(tsx?|jsx?|mjs)$/.test(id) && name === "default") return false; + return true; +} + +function validateModuleExport(transforms: RscTransforms, meta: ModuleExportMeta): void { + if (!meta.valueNode) return; + if ( + meta.isFunction !== false && + (meta.valueNode.type === "ObjectExpression" || meta.valueNode.type === "ArrayExpression") + ) { + return; + } + transforms.validateNonAsyncFunction({ rejectNonAsyncFunction: true }, meta.valueNode); +} + +function hasFunctionDirective( + meta: Pick, + directive: string, +): boolean { + const node = meta.valueNode; + if ( + (node?.type !== "FunctionDeclaration" && + node?.type !== "FunctionExpression" && + node?.type !== "ArrowFunctionExpression") || + node.body.type !== "BlockStatement" + ) { + return false; + } + return node.body.body.some( + (statement) => + statement.type === "ExpressionStatement" && + "directive" in statement && + statement.directive === directive, + ); +} + +function getCacheWrapperOptions( + options: Options, + id: string, + name: string, + isModuleDirective: boolean, + meta: Pick | TransformHoistInlineDirectiveMeta, +): CacheWrapperOptions { + const argumentCount = getArgumentCount(meta); + return { + acceptsSecondArgument: acceptsSecondArgument(meta), + ...(isAppPageDefaultExport(options, id, name, isModuleDirective) + ? { appPageDefaultExport: true } + : {}), + ...(argumentCount === undefined ? {} : { argumentCount }), + }; +} + +export async function createUseCacheCallablePlugin(options: Options): Promise { + const rscModulePath = resolvePluginRscModule(options.projectRoot, "@vitejs/plugin-rsc"); + const transformsPath = resolvePluginRscModule( + options.projectRoot, + "@vitejs/plugin-rsc/transforms", + ); + const rscModule: typeof import("@vitejs/plugin-rsc") = await import( + pathToFileURL(rscModulePath).href + ); + const transforms: RscTransforms = await import(pathToFileURL(transformsPath).href); + let manager: RscPluginManager | undefined; + + return { + name: PLUGIN_NAME, + configResolved(config) { + const pluginApi = rscModule.getPluginApi(config); + const hasRscPlugin = config.plugins.some((plugin) => plugin.name === "rsc"); + if (!pluginApi && options.allowMissingRsc && !hasRscPlugin) return; + if (!pluginApi?.manager.serverReferences) { + throw new Error("vinext: callable use cache requires @vitejs/plugin-rsc 0.5.34 or newer."); + } + if (options.allowMissingRsc) { + const useCacheIndex = config.plugins.findIndex((plugin) => plugin.name === PLUGIN_NAME); + const useServerIndex = config.plugins.findIndex( + (plugin) => plugin.name === "rsc:use-server", + ); + if (useServerIndex !== -1 && useCacheIndex > useServerIndex) { + throw new Error( + "vinext: when configuring @vitejs/plugin-rsc manually, vinext({ rsc: false }) must appear before rsc() in the Vite plugins array.", + ); + } + } + manager = pluginApi.manager; + }, + transform: { + filter: { + id: { + include: SOURCE_MODULE_ID_RE, + exclude: [DEPENDENCY_MODULE_ID_RE, RESOLVED_VIRTUAL_MODULE_ID_RE], + }, + }, + async handler(code, id) { + if (!manager) return; + if (!code.includes("use cache")) { + manager.serverReferences.deleteClaim(PLUGIN_NAME, id); + return; + } + + const ast = await parseAstAsync(code); + const moduleDirective = findModuleUseCacheDirective(ast); + const useServerBoundary = transforms.hasDirective(ast.body, "use server"); + if (moduleDirective && useServerBoundary) { + throw new Error( + `A module cannot contain both ${JSON.stringify(moduleDirective)} and "use server" directives.`, + ); + } + + const reference = manager.serverReferences.resolve(id, "rsc"); + const isRsc = this.environment.name === "rsc"; + + if (!isRsc) { + if (useServerBoundary) { + manager.serverReferences.deleteClaim(PLUGIN_NAME, id); + return; + } + if (!moduleDirective) { + transforms.transformHoistInlineDirective(code, ast, { + directive: USE_CACHE_DIRECTIVE_CANDIDATE, + rejectNonAsyncFunction: true, + runtime: (_value, _name, meta) => { + matchUseCacheDirective(meta.directiveMatch[0]); + throw new Error( + `It is not allowed to define inline "use cache" annotated functions in Client Components. Export them from a separate file with a module-level "use cache" or "use server" directive, or pass them down through props from a Server Component. (${this.environment.name}: ${id})`, + ); + }, + }); + manager.serverReferences.deleteClaim(PLUGIN_NAME, id); + return; + } + + const result = transforms.transformDirectiveProxyExport(ast, { + code, + directive: moduleDirective, + filter: (name, meta) => { + if (!shouldTransformModuleExport(name, id, meta)) return false; + validateModuleExport(transforms, meta); + return true; + }, + runtime: (name) => + `$$ReactClient.createServerReference(${JSON.stringify(`${reference.referenceKey}#${name}`)},$$ReactClient.callServer,undefined,${this.environment.mode === "dev" ? "$$ReactClient.findSourceMapURL" : "undefined"},${JSON.stringify(name)})`, + }); + if (!result?.output.hasChanged()) { + manager.serverReferences.deleteClaim(PLUGIN_NAME, id); + return; + } + + manager.serverReferences.replaceClaim(PLUGIN_NAME, id, { + ...reference, + exportNames: result.exportNames, + }); + const runtimeEnvironment = this.environment.name === "client" ? "browser" : "ssr"; + result.output.prepend( + `import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`, + ); + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: "boundary", source: id }), + }; + } + + const wrap = ( + value: string, + name: string, + directiveMatch: RegExpMatchArray, + meta: Pick | TransformHoistInlineDirectiveMeta, + isModuleDirective: boolean, + ) => { + const variant = directiveMatch[1] ?? ""; + const wrapperOptions = getCacheWrapperOptions(options, id, name, isModuleDirective, meta); + return `$$cacheRuntime.registerCachedFunction(${value}, ${JSON.stringify(`${id}:${name}`)}, ${JSON.stringify(variant)}, ${JSON.stringify(wrapperOptions)})`; + }; + let needsReactServer = false; + const runtime = ( + value: string, + name: string, + directiveMatch: RegExpMatchArray, + meta: Pick | TransformHoistInlineDirectiveMeta, + isModuleDirective: boolean, + ) => { + const cached = wrap(value, name, directiveMatch, meta, isModuleDirective); + needsReactServer = true; + return `$$VinextReactServer.registerServerReference(${cached}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`; + }; + + const result = moduleDirective + ? transforms.transformWrapExport(code, ast, { + filter: (name, meta) => { + if ( + hasFunctionDirective(meta, "use server") || + !shouldTransformModuleExport(name, id, meta) + ) { + return false; + } + validateModuleExport(transforms, meta); + return true; + }, + runtime: (value, name, meta) => + runtime(value, name, matchUseCacheDirective(moduleDirective), meta, true), + }) + : transforms.transformHoistInlineDirective(code, ast, { + directive: USE_CACHE_DIRECTIVE_CANDIDATE, + rejectNonAsyncFunction: true, + hoistRuntime: true, + runtime: (value, name, meta) => + runtime(value, name, matchUseCacheDirective(meta.directiveMatch[0]), meta, false), + encode: (value) => `$$cacheRuntime.encryptCacheCaptures(${value})`, + decode: (value) => value, + }); + if (!result.output.hasChanged()) { + manager.serverReferences.deleteClaim(PLUGIN_NAME, id); + return; + } + + manager.serverReferences.replaceClaim(PLUGIN_NAME, id, { + ...reference, + exportNames: "names" in result ? result.names : result.exportNames, + }); + const importPosition = + ast.body.find((node) => !("directive" in node))?.start ?? code.length; + result.output.prependLeft( + importPosition, + [ + `import * as $$cacheRuntime from ${JSON.stringify(options.cacheRuntime)};`, + needsReactServer && + `import * as $$VinextReactServer from "@vitejs/plugin-rsc/react/rsc/server";`, + ] + .filter(Boolean) + .join("\n") + "\n", + ); + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: "boundary", source: id }), + }; + }, + }, + }; +} diff --git a/packages/vinext/src/shims/cache-callable-runtime.ts b/packages/vinext/src/shims/cache-callable-runtime.ts new file mode 100644 index 0000000000..28cb3f9d33 --- /dev/null +++ b/packages/vinext/src/shims/cache-callable-runtime.ts @@ -0,0 +1,56 @@ +import { + decryptActionBoundArgs, + encryptActionBoundArgs, +} from "@vitejs/plugin-rsc/utils/encryption-runtime"; +import { + registerCachedFunction as registerCachedFunctionBase, + type RegisterCachedFunctionOptions, +} from "./cache-runtime.js"; + +const CACHE_CAPTURE_TYPE = "use-cache-captures"; + +type CacheCaptureEnvelope = { + type: typeof CACHE_CAPTURE_TYPE; + encrypted: string | PromiseLike; +}; + +export function encryptCacheCaptures(captures: unknown[]): CacheCaptureEnvelope { + return { + type: CACHE_CAPTURE_TYPE, + encrypted: encryptActionBoundArgs(captures), + }; +} + +async function decryptCacheCaptures(value: unknown): Promise { + if (!isCacheCaptureEnvelope(value)) return; + const captures = await decryptActionBoundArgs(Promise.resolve(value.encrypted)); + if (!Array.isArray(captures)) throw new Error("Invalid cache capture payload"); + return captures; +} + +function isCacheCaptureEnvelope(value: unknown): value is CacheCaptureEnvelope { + if (typeof value !== "object" || value === null) return false; + if (!("type" in value) || value.type !== CACHE_CAPTURE_TYPE || !("encrypted" in value)) { + return false; + } + const encrypted = value.encrypted; + return ( + typeof encrypted === "string" || + (typeof encrypted === "object" && + encrypted !== null && + "then" in encrypted && + typeof encrypted.then === "function") + ); +} + +export function registerCachedFunction( + fn: (...args: TArgs) => Promise, + id: string, + variant: string, + options: RegisterCachedFunctionOptions, +): (...args: TArgs) => Promise { + return registerCachedFunctionBase(fn, id, variant, { + ...options, + decryptCaptures: decryptCacheCaptures, + }); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index d18630d793..dbc23f4700 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -174,7 +174,11 @@ export function getCacheContext(): CacheContext | null { */ type RscModule = { renderToReadableStream: (data: unknown, options?: object) => ReadableStream; - createFromReadableStream: (stream: ReadableStream, options?: object) => Promise; + createFromReadableStream: ( + stream: ReadableStream, + options?: object, + context?: { preserveServerReferences?: boolean }, + ) => Promise; encodeReply: (v: unknown[], options?: unknown) => Promise; createTemporaryReferenceSet: () => unknown; createClientTemporaryReferenceSet: () => unknown; @@ -427,7 +431,7 @@ export function clearPrivateCache(): void { // Core runtime: registerCachedFunction // --------------------------------------------------------------------------- -type RegisterCachedFunctionOptions = { +export type RegisterCachedFunctionOptions = { /** * Whether the original function declaration accepts a second argument. * Function.length cannot represent default or rest parameters, so the @@ -442,6 +446,9 @@ type RegisterCachedFunctionOptions = { * rather than on the intermediate createElement config object. */ appPageDefaultExport?: boolean; + /** Number of declared arguments supplied by the directive transform. */ + argumentCount?: number; + decryptCaptures?: (value: unknown) => Promise; }; /** @@ -474,6 +481,18 @@ export function registerCachedFunction( trackPprFallbackShellCacheTask(async (): Promise => { const rsc = await getRscModule(); const keySeed = getUseCacheKeySeed(); + const captures = options.decryptCaptures ? await options.decryptCaptures(args[0]) : undefined; + const hasCaptureEnvelope = captures !== undefined; + const admittedArgs = + options.argumentCount === undefined + ? args + : hasCaptureEnvelope + ? [args[0], ...args.slice(1, 1 + options.argumentCount)] + : args.slice(0, options.argumentCount); + const executionArgs = hasCaptureEnvelope + ? [captures, ...admittedArgs.slice(1)] + : admittedArgs; + const callArgs = executionArgs as TArgs; // Build the cache key. Use encodeReply (RSC protocol) when available — // it correctly handles React elements as temporary references (excluded @@ -481,10 +500,10 @@ export function registerCachedFunction( let cacheKey: string; try { const processedArgs = - args.length > 0 - ? unwrapThenableObjectArray(args, { omitAppPageSearchParamsFromFirstArg }) + executionArgs.length > 0 + ? unwrapThenableObjectArray(executionArgs, { omitAppPageSearchParamsFromFirstArg }) : []; - if (rsc && args.length > 0) { + 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(); @@ -506,7 +525,7 @@ export function registerCachedFunction( } } catch { // Non-serializable arguments — run without caching - return fn(...args); + return fn(...callArgs); } // "use cache: private" uses per-request in-memory cache @@ -531,7 +550,7 @@ export function registerCachedFunction( return privateHit as TResult; } - const result = await executeWithContext(fn, args, cacheVariant); + const result = await executeWithContext(fn, callArgs, cacheVariant); privateCache.set(cacheKey, result); return result; } @@ -541,7 +560,7 @@ export function registerCachedFunction( // preview request would otherwise seed unpublished content into an entry // later served to public requests. Mirrors Next.js's `isDraftMode` guard. if (isDev || isDraftModeEnabled()) { - return executeWithContext(fn, args, cacheVariant); + return executeWithContext(fn, callArgs, cacheVariant); } // Shared cache ("use cache" / "use cache: remote") @@ -582,7 +601,11 @@ export function registerCachedFunction( // RSC-serialized entry: base64 → bytes → stream → deserialize const bytes = base64ToUint8(existing.value.data.body); const stream = uint8ToStream(bytes); - const result = await rsc.createFromReadableStream(stream); + const result = await rsc.createFromReadableStream( + stream, + {}, + { preserveServerReferences: true }, + ); recordRequestScopedCacheControl(existing.cacheControl); return result; } @@ -598,7 +621,7 @@ export function registerCachedFunction( // Cache miss (or stale) — execute with context const { result, ctx, effectiveLife } = await runCachedFunctionWithContext( fn, - args, + callArgs, cacheVariant, ); diff --git a/playwright.config.ts b/playwright.config.ts index eb623abd56..477998dbb6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -56,8 +56,8 @@ const projectServers = { server: appRouterServer, }, "app-router-isr-prod": { - testDir: "./tests/e2e/app-router", - testMatch: "isr.spec.ts", + testDir: "./tests/e2e", + testMatch: ["app-router/isr.spec.ts", "app-router-prod/use-cache.spec.ts"], use: { baseURL: "http://localhost:4198" }, server: { command: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8591f7c77..5f39e594a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,8 +91,8 @@ catalogs: specifier: ^6.0.1 version: 6.0.1 '@vitejs/plugin-rsc': - specifier: ^0.5.27 - version: 0.5.27 + specifier: ^0.5.34 + version: 0.5.34 '@vitest/coverage-istanbul': specifier: 4.1.10 version: 4.1.10 @@ -231,9 +231,6 @@ catalogs: vite-plus: specifier: 0.2.6 version: 0.2.6 - vitest: - specifier: 4.1.10 - version: 4.1.10 web-vitals: specifier: ^4.2.4 version: 4.2.4 @@ -286,7 +283,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) @@ -322,7 +319,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) + version: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) vitest: specifier: 4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/browser-preview@4.1.10)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2)) @@ -380,7 +377,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) drizzle-kit: specifier: 'catalog:' version: 0.31.10 @@ -423,7 +420,7 @@ importers: devDependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.6 version: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' @@ -444,7 +441,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -579,7 +576,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -618,7 +615,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -701,7 +698,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) postcss: specifier: 'catalog:' version: 8.5.10 @@ -734,7 +731,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) ms: specifier: 'catalog:' version: 3.0.0-canary.1 @@ -805,7 +802,7 @@ importers: version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) typescript: specifier: 'catalog:' version: 7.0.2 @@ -946,7 +943,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -992,7 +989,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1098,7 +1095,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) am-i-vibing: specifier: 'catalog:' version: 0.5.0 @@ -1116,7 +1113,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) fake-context-lib: specifier: file:./__test_packages__/fake-context-lib version: file:tests/fixtures/app-basic/__test_packages__/fake-context-lib @@ -1150,7 +1147,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1175,7 +1172,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1208,7 +1205,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1242,7 +1239,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1279,7 +1276,7 @@ importers: version: 6.0.1(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1345,7 +1342,7 @@ importers: version: 1.9.1 '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.5.6(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260313.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.6.2)(kysely@0.28.15))(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10) @@ -1376,7 +1373,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-intl: specifier: 'catalog:' version: 4.11.1(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)(typescript@7.0.2) @@ -1404,7 +1401,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1432,7 +1429,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-view-transitions: specifier: 'catalog:' version: 0.3.5(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1460,7 +1457,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) nuqs: specifier: 'catalog:' version: 2.8.8(next@16.2.7(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) @@ -1497,7 +1494,7 @@ importers: version: 1.2.4(@types/react@19.2.16)(react@19.2.7) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -1553,7 +1550,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1578,7 +1575,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1603,7 +1600,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1696,7 +1693,7 @@ importers: version: 1.31.0(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(workerd@1.20260401.1)(wrangler@4.80.0) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1729,7 +1726,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1776,7 +1773,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1797,7 +1794,7 @@ importers: dependencies: '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -5114,8 +5111,8 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.27': - resolution: {integrity: sha512-s1fd5DUkPXk86DDHPM/kP93WrvI0MoA8klxdDZmD1fMSaA9xujfgunsm8ZoUH0FemR+63vNalFsIDR0AJH4ktg==} + '@vitejs/plugin-rsc@0.5.34': + resolution: {integrity: sha512-95V6fyGQklQMYIWTr5qwwNmpDYxsHkTunuzq8i/keIcgdckL9zb6nyEgTnb1CEl1IPJWVxGgORMkTFHrct7XJg==} peerDependencies: react: '*' react-dom: '*' @@ -7772,6 +7769,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.12.5: + resolution: {integrity: sha512-IuvtDNQg5EIwv3c6dleyau7u8hCyGQ7D6+V/QM799Aud07z0wCUcurKLTRfyG33C8oUY+UWcVBFkfHMcbtmRLA==} + engines: {node: '>=20.16.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -10980,15 +10982,15 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' - '@vitejs/plugin-rsc@0.5.27(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@vitejs/plugin-rsc@0.5.34(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': dependencies: '@rolldown/pluginutils': 1.0.1 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 estree-walker: 3.0.3 magic-string: 0.30.21 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - srvx: 0.11.13 + srvx: 0.12.5 strip-literal: 3.1.0 turbo-stream: 3.2.0 vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' @@ -13220,31 +13222,6 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.60.0 vite-plus: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) - oxfmt@0.60.0(vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)): - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.60.0 - '@oxfmt/binding-android-arm64': 0.60.0 - '@oxfmt/binding-darwin-arm64': 0.60.0 - '@oxfmt/binding-darwin-x64': 0.60.0 - '@oxfmt/binding-freebsd-x64': 0.60.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.60.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.60.0 - '@oxfmt/binding-linux-arm64-gnu': 0.60.0 - '@oxfmt/binding-linux-arm64-musl': 0.60.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.60.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.60.0 - '@oxfmt/binding-linux-riscv64-musl': 0.60.0 - '@oxfmt/binding-linux-s390x-gnu': 0.60.0 - '@oxfmt/binding-linux-x64-gnu': 0.60.0 - '@oxfmt/binding-linux-x64-musl': 0.60.0 - '@oxfmt/binding-openharmony-arm64': 0.60.0 - '@oxfmt/binding-win32-arm64-msvc': 0.60.0 - '@oxfmt/binding-win32-ia32-msvc': 0.60.0 - '@oxfmt/binding-win32-x64-msvc': 0.60.0 - vite-plus: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) - oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 @@ -13278,30 +13255,6 @@ snapshots: oxlint-tsgolint: 7.0.2001 vite-plus: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) - oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.75.0 - '@oxlint/binding-android-arm64': 1.75.0 - '@oxlint/binding-darwin-arm64': 1.75.0 - '@oxlint/binding-darwin-x64': 1.75.0 - '@oxlint/binding-freebsd-x64': 1.75.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 - '@oxlint/binding-linux-arm-musleabihf': 1.75.0 - '@oxlint/binding-linux-arm64-gnu': 1.75.0 - '@oxlint/binding-linux-arm64-musl': 1.75.0 - '@oxlint/binding-linux-ppc64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-gnu': 1.75.0 - '@oxlint/binding-linux-riscv64-musl': 1.75.0 - '@oxlint/binding-linux-s390x-gnu': 1.75.0 - '@oxlint/binding-linux-x64-gnu': 1.75.0 - '@oxlint/binding-linux-x64-musl': 1.75.0 - '@oxlint/binding-openharmony-arm64': 1.75.0 - '@oxlint/binding-win32-arm64-msvc': 1.75.0 - '@oxlint/binding-win32-ia32-msvc': 1.75.0 - '@oxlint/binding-win32-x64-msvc': 1.75.0 - oxlint-tsgolint: 7.0.2001 - vite-plus: 0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) - p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -13883,6 +13836,8 @@ snapshots: srvx@0.11.13: {} + srvx@0.12.5: {} + stackback@0.0.2: {} stacktrace-parser@0.1.11: @@ -14269,64 +14224,6 @@ snapshots: - vite - yaml - vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0): - dependencies: - '@oxc-project/types': 0.141.0 - '@oxlint/plugins': 1.73.0 - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(vitest@4.1.10) - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(vitest@4.1.10) - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - '@voidzero-dev/vite-plus-core': 0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0) - oxfmt: 0.60.0(vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) - oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.6(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2))(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)) - oxlint-tsgolint: 7.0.2001 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/browser-preview@4.1.10)(@vitest/coverage-istanbul@4.1.10(vitest@4.1.10))(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0))(msw@2.14.6(@types/node@25.9.2)(typescript@7.0.2)) - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.2.6 - '@voidzero-dev/vite-plus-darwin-x64': 0.2.6 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.6 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.6 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.6 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.6 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.6 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.6 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - - publint - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)): optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@7.0.2)(yaml@2.9.0)' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bec975715f..971ebc9980 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,7 +44,7 @@ catalog: "@next/mdx": 16.2.7 recma-codehike: 0.0.1 remark-codehike: 0.0.1 - "@vitejs/plugin-rsc": ^0.5.27 + "@vitejs/plugin-rsc": ^0.5.34 "@vitest/coverage-istanbul": 4.1.10 better-auth: ^1.5.6 better-sqlite3: ^12.0.0 diff --git a/tests/app-router-production-server.test.ts b/tests/app-router-production-server.test.ts index d8b8514e47..7e4d7570fe 100644 --- a/tests/app-router-production-server.test.ts +++ b/tests/app-router-production-server.test.ts @@ -2189,6 +2189,98 @@ describe("App Router Production server (startProdServer)", () => { expect(body3.timestamp).not.toBe(body1.timestamp); }); + // Ported from Next.js: test/e2e/app-dir/use-cache-with-server-function-props + // ("should be able to use nested cache functions as props"). + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-with-server-function-props/use-cache-with-server-function-props.test.ts + // + // Inline "use cache" functions defined inside a cached component and passed + // as props to a client component must (a) serialize as server references in + // the RSC payload and (b) resolve back through the production + // server-references manifest on the action POST. (b) can only fail in + // production builds — the manifest is keyed by the plugin-rsc normalised + // reference key and generated from serverReferenceMetaMap — so this test + // must run against the built output, not the dev server. + it("resolves nested 'use cache' functions passed as props when invoked as actions", async () => { + const res = await fetch(`${baseUrl}/use-cache-nested-fn-props`); + expect(res.status).toBe(200); + const html = await res.text(); + + // React Flight encodes server-function props with the `$h` token used by + // this React build's SERVER_DECODE_REFERENCE_PREFIX path. Keep this + // assertion next to the action round-trip so the test proves both halves: + // the payload uses the server-reference encoding and the decoded reference + // resolves through vinext's production manifest below. + expect(html).toContain('\\"getDate\\":\\"$h'); + + // The flight payload embeds each cached function prop as a server + // reference whose id is "<12-hex normalised key>#". + const refIds = [...new Set(html.match(/[0-9a-f]{12}#\$\$hoist_\d+_[A-Za-z0-9_$]+/g) ?? [])]; + expect(refIds.length).toBe(3); + const [getDateRefId, getRandomRefId, getMessageRefId] = refIds; + + // The fixture's getMessage closes over this string. Match Next.js and + // plugin-rsc's "use server" transform by serializing an encrypted binding, + // never the plaintext capture, into the Flight payload. + const capturedScopeValue = "closure-captured-bound-arg-vinext"; + expect(html).not.toContain(capturedScopeValue); + const encryptedBoundArg = html.match(/rsc\.push\("[0-9a-f]+:\\"([A-Za-z0-9+/=]{64,})\\"/)?.[1]; + expect(encryptedBoundArg).toBeDefined(); + const encryptedCaptureEnvelope = { + type: "use-cache-captures", + encrypted: encryptedBoundArg, + }; + + const invokeAction = async (actionId: string, args: unknown[] = []): Promise => { + const actionRes = await fetch(`${baseUrl}/use-cache-nested-fn-props.rsc`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + "x-rsc-action": actionId, + }, + body: JSON.stringify(args), + }); + expect(actionRes.status).toBe(200); + expect(actionRes.headers.get("x-nextjs-action-not-found")).toBeNull(); + const text = await actionRes.text(); + expect(text).not.toContain("Server action not found"); + return text; + }; + + const isoDateRegExp = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/; + const date1 = (await invokeAction(getDateRefId)).match(isoDateRegExp)?.[0]; + expect(date1).toBeDefined(); + + // The resolved server reference is the cached wrapper (Next.js parity: + // the exported cached binding IS the server reference), so a second + // invocation with identical arguments returns the cached value. + const date2 = (await invokeAction(getDateRefId)).match(isoDateRegExp)?.[0]; + expect(date2).toBe(date1); + + const randomText = await invokeAction(getRandomRefId); + expect(randomText).toMatch(/\d+\.\d+/); + + // Closure round-trip: the client sends the encrypted binding ahead of the + // call args. The server-reference wrapper decrypts it before entering the + // cached function, so plaintext values still determine the cache key. + const messageRegExpFor = (boundArg: string): RegExp => + new RegExp(`message:${boundArg}:[0-9.e+-]+`); + const message1 = (await invokeAction(getMessageRefId, [encryptedCaptureEnvelope])).match( + messageRegExpFor(capturedScopeValue), + )?.[0]; + expect(message1).toBeDefined(); + + // Cached-invoke semantics for the closure-BOUND path, mirroring the + // getDate assertion above so caching is pinned across both paths + // (unbound getDate AND bound getMessage): the fixture appends a + // Math.random() suffix, so a second invocation with the same bound arg + // can only return the identical value if the bound arg produced the same + // cache key and the entry was hit (a recompute would change the suffix). + const message2 = (await invokeAction(getMessageRefId, [encryptedCaptureEnvelope])).match( + messageRegExpFor(capturedScopeValue), + )?.[0]; + expect(message2).toBe(message1); + }); + it("middleware request header overrides still apply after middleware calls headers() first", async () => { // Regression for a bug where a middleware that reads `next/headers` → // `headers()` *before* returning `NextResponse.next({ request: { headers } })` diff --git a/tests/app-router-rsc-plugin.test.ts b/tests/app-router-rsc-plugin.test.ts index 2d9f01e256..92b768e7fa 100644 --- a/tests/app-router-rsc-plugin.test.ts +++ b/tests/app-router-rsc-plugin.test.ts @@ -111,6 +111,21 @@ describe("RSC plugin auto-registration", () => { } }, 30000); + it("rejects a manually registered RSC plugin placed before vinext", async () => { + const { createServer } = await import("vite"); + const rsc = (await import("@vitejs/plugin-rsc")).default; + await expect( + createServer({ + root: APP_FIXTURE_DIR, + configFile: false, + plugins: [rsc({ entries: RSC_ENTRIES }), vinext({ appDir: APP_FIXTURE_DIR, rsc: false })], + optimizeDeps: { holdUntilCrawlEnd: true }, + server: { port: 0, cors: false }, + logLevel: "silent", + }), + ).rejects.toThrow("vinext({ rsc: false }) must appear before rsc()"); + }, 30000); + it("throws an error when user double-registers rsc() alongside auto-registration", async () => { const { createBuilder } = await import("vite"); const rsc = (await import("@vitejs/plugin-rsc")).default; diff --git a/tests/e2e/app-router-prod/use-cache.spec.ts b/tests/e2e/app-router-prod/use-cache.spec.ts new file mode 100644 index 0000000000..cfc2659589 --- /dev/null +++ b/tests/e2e/app-router-prod/use-cache.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; + +test.describe('production "use cache" server function references', () => { + test("separates arguments for file-level cached exports imported by a Client Component", async ({ + page, + }) => { + await page.goto("/use-cache-client-import"); + await waitForAppRouterHydration(page); + + await page.locator("#call-client-imported-cache").click(); + await expect(page.getByTestId("client-imported-cache-call-count")).toHaveText("1"); + await expect(page.getByTestId("client-imported-cache-result")).toHaveText( + /^client-cache:direct:[0-9.e+-]+$/, + ); + const directResult = await page.getByTestId("client-imported-cache-result").innerText(); + + await page.locator("#call-client-imported-cache-other").click(); + await expect(page.getByTestId("client-imported-cache-call-count")).toHaveText("2"); + await expect(page.getByTestId("client-imported-cache-result")).toHaveText( + /^client-cache:other:[0-9.e+-]+$/, + ); + const otherResult = await page.getByTestId("client-imported-cache-result").innerText(); + expect(otherResult).not.toBe(directResult); + + await page.locator("#call-client-imported-cache").click(); + await expect(page.getByTestId("client-imported-cache-call-count")).toHaveText("3"); + await expect(page.getByTestId("client-imported-cache-result")).toHaveText(directResult); + + await page.locator("#call-client-imported-server").click(); + await expect(page.getByTestId("client-imported-cache-call-count")).toHaveText("4"); + await expect(page.getByTestId("client-imported-cache-result")).toHaveText( + /^client-server:direct:[0-9.e+-]+$/, + ); + const firstServerResult = await page.getByTestId("client-imported-cache-result").innerText(); + + await page.locator("#call-client-imported-server").click(); + await expect(page.getByTestId("client-imported-cache-call-count")).toHaveText("5"); + await expect(page.getByTestId("client-imported-cache-result")).not.toHaveText( + firstServerResult, + ); + }); + + test("runs inline use-server and use-cache exports owned by different plugins", async ({ + page, + }) => { + await page.goto("/use-cache-mixed-ownership"); + await expect(page.getByTestId("use-cache-mixed-ownership-page")).toBeVisible(); + await waitForAppRouterHydration(page); + + await page.locator("#call-mixed-builtin").click(); + await expect(page.getByTestId("mixed-builtin-call-count")).toHaveText("1"); + await expect(page.getByTestId("mixed-builtin-result")).toHaveText("builtin"); + + await page.locator("#call-mixed-flexible").click(); + await expect(page.getByTestId("mixed-flexible-call-count")).toHaveText("1"); + const cachedResult = await page.getByTestId("mixed-flexible-result").innerText(); + expect(cachedResult).toMatch(/^cached:[0-9.e+-]+$/); + + await page.locator("#call-mixed-flexible").click(); + await expect(page.getByTestId("mixed-flexible-call-count")).toHaveText("2"); + await expect(page.getByTestId("mixed-flexible-result")).toHaveText(cachedResult); + }); + + test('caches an inline "use cache" function during server render inside a file-level "use server" module', async ({ + page, + }) => { + await page.goto("/use-cache-transform-coverage"); + + const aggregateResult = await page.getByTestId("use-cache-transform-coverage").innerText(); + const serverResult = aggregateResult.split("|")[1]; + if (!serverResult) throw new Error("Missing server-boundary result"); + expect(serverResult).toMatch(/^server-boundary:[0-9.e+-]+$/); + + await page.reload(); + const repeatedResult = await page.getByTestId("use-cache-transform-coverage").innerText(); + expect(repeatedResult.split("|")[1]).toBe(serverResult); + }); + + test("replays cached RSC through SSR and invokes nested functions from the browser", async ({ + page, + }) => { + await page.goto("/use-cache-nested-fn-props"); + await expect(page.getByTestId("use-cache-nested-fn-props-page")).toBeVisible(); + const cachedRender = await page.getByTestId("nested-cache-render").textContent(); + + // Force a second server request so this test exercises the cache-hit Flight + // replay path before invoking the nested references in the browser. + await page.reload(); + await expect(page.getByTestId("use-cache-nested-fn-props-page")).toBeVisible(); + await expect(page.getByTestId("nested-cache-render")).toHaveText(cachedRender!); + await waitForAppRouterHydration(page); + + await page.locator("#submit-button-date").click(); + await expect(page.locator("#date")).toHaveText(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + const firstDate = await page.locator("#date").textContent(); + await page.locator("#submit-button-date").click(); + await expect(page.locator("#date")).toHaveText(firstDate!); + + await page.locator("#submit-button-random").click(); + await expect(page.locator("#random")).toHaveText(/^\d+\.\d+$/); + const firstRandom = await page.locator("#random").textContent(); + await page.locator("#submit-button-random").click(); + await expect(page.locator("#random")).toHaveText(firstRandom!); + + await page.locator("#submit-button-message").click(); + await expect(page.locator("#message")).toHaveText( + /^message:closure-captured-bound-arg-vinext:[0-9.e+-]+$/, + ); + const firstMessage = await page.locator("#message").textContent(); + await page.locator("#submit-button-message").click(); + await expect(page.locator("#message")).toHaveText(firstMessage!); + + await page.locator("#submit-button-message-other").click(); + await expect(page.locator("#message-other")).toHaveText( + /^message:closure-captured-bound-arg-other:[0-9.e+-]+$/, + ); + const otherMessage = await page.locator("#message-other").textContent(); + expect(otherMessage).not.toBe(firstMessage); + await page.locator("#submit-button-message-other").click(); + await expect(page.locator("#message-other")).toHaveText(otherMessage!); + }); +}); diff --git a/tests/e2e/app-router/use-cache.spec.ts b/tests/e2e/app-router/use-cache.spec.ts index 2752d42c40..68ac1b9bf2 100644 --- a/tests/e2e/app-router/use-cache.spec.ts +++ b/tests/e2e/app-router/use-cache.spec.ts @@ -1,6 +1,95 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; const BASE = "http://localhost:4174"; +const USE_CACHE_HMR_ACTIONS_FILE = path.join( + process.cwd(), + "tests/fixtures/app-basic/app/use-cache-hmr/actions.ts", +); +const USE_CACHE_MIXED_HMR_ACTIONS_FILE = path.join( + process.cwd(), + "tests/fixtures/app-basic/app/use-cache-mixed-ownership/actions.ts", +); +const USE_CACHE_HMR_DEPENDENCY_FILE = path.join( + process.cwd(), + "tests/fixtures/app-basic/app/use-cache-hmr/dependency.ts", +); +const USE_CACHE_HMR_CACHED = `"use cache"; + +export async function getMode() { + return "cached"; +} +`; +const USE_CACHE_HMR_PLAIN = `"use server"; + +export async function getMode() { + return "plain"; +} +`; +const USE_CACHE_HMR_IMPORTED = `"use cache"; + +import { dependencyMode } from "./dependency"; + +export async function getMode() { + return dependencyMode; +} +`; +const USE_CACHE_HMR_DEPENDENCY_INITIAL = `export const dependencyMode = "dependency-initial";\n`; +const USE_CACHE_HMR_DEPENDENCY_UPDATED = `export const dependencyMode = "dependency-updated";\n`; +const USE_CACHE_MIXED_HMR_CACHED = [ + `export const ownershipLabel = "cache";`, + ``, + `export async function builtinAction() {`, + ` "use server";`, + ` return "builtin";`, + `}`, + ``, + `export async function flexibleAction() {`, + ` "use cache";`, + ` return \`cached:\${Math.random()}\`;`, + `}`, + ``, +].join("\n"); +const USE_CACHE_MIXED_HMR_SERVER = [ + `export const ownershipLabel = "server";`, + ``, + `export async function builtinAction() {`, + ` "use server";`, + ` return "builtin";`, + `}`, + ``, + `export async function flexibleAction() {`, + ` "use server";`, + ` return "server";`, + `}`, + ``, +].join("\n"); + +async function writeUseCacheHmrActions(content: string, forceUpdate = false) { + const updateMarker = forceUpdate ? `// hmr-update:${Date.now()}` : undefined; + const nextContent = updateMarker ? `${content}${updateMarker}\n` : content; + if ((await readFile(USE_CACHE_HMR_ACTIONS_FILE, "utf8")) !== nextContent) { + await writeFile(USE_CACHE_HMR_ACTIONS_FILE, nextContent); + } + return updateMarker; +} + +async function waitForUseCacheHmrTransform(request: APIRequestContext, updateMarker?: string) { + await expect + .poll(async () => { + const response = await request.get(`${BASE}/app/use-cache-hmr/actions.ts?t=${Date.now()}`); + return response.ok() && (!updateMarker || (await response.text()).includes(updateMarker)); + }) + .toBe(true); +} + +async function writeUseCacheMixedHmrActions(content: string, forceUpdate = false) { + const nextContent = forceUpdate ? `${content}// hmr-update:${Date.now()}\n` : content; + if ((await readFile(USE_CACHE_MIXED_HMR_ACTIONS_FILE, "utf8")) !== nextContent) { + await writeFile(USE_CACHE_MIXED_HMR_ACTIONS_FILE, nextContent); + } +} test.describe('"use cache" file-level directive', () => { test("use-cache page renders correctly", async ({ page }) => { @@ -99,6 +188,202 @@ test.describe('"use cache" function-level directive', () => { }); }); +test.describe('"use cache" direct client imports', () => { + test("file-level cached exports become callable server references", async ({ page }) => { + await page.goto(`${BASE}/use-cache-client-import`); + await expect(async () => { + await page.locator("#call-client-imported-cache").click(); + await expect(page.getByTestId("client-imported-cache-result")).toHaveText( + /^client-cache:direct:[0-9.e+-]+$/, + { timeout: 2000 }, + ); + }).toPass({ timeout: 15_000 }); + }); +}); + +test.describe('"use cache" transform coverage', () => { + test("supports released transform forms and use-server boundaries", async ({ page }) => { + await page.goto(`${BASE}/use-cache-transform-coverage`); + await expect(page.getByTestId("use-cache-transform-coverage")).toHaveText( + /^destructured\|server-boundary:[0-9.e+-]+\|custom-kind$/, + ); + }); + + test("removes and restores directive metadata during HMR", async ({ page, request }) => { + const initialUpdate = await writeUseCacheHmrActions(USE_CACHE_HMR_CACHED, true); + try { + await waitForUseCacheHmrTransform(request, initialUpdate); + await page.goto(`${BASE}/use-cache-hmr`); + await expect(async () => { + await page.locator("#call-use-cache-hmr").click(); + await expect(page.getByTestId("use-cache-hmr-result")).toHaveText("cached", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + + const plainUpdate = await writeUseCacheHmrActions(USE_CACHE_HMR_PLAIN, true); + await waitForUseCacheHmrTransform(request, plainUpdate); + await page.reload(); + await expect(async () => { + await page.locator("#call-use-cache-hmr").click(); + await expect(page.getByTestId("use-cache-hmr-result")).toHaveText("plain", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + + const cachedUpdate = await writeUseCacheHmrActions(USE_CACHE_HMR_CACHED, true); + await waitForUseCacheHmrTransform(request, cachedUpdate); + await page.reload(); + await expect(async () => { + await page.locator("#call-use-cache-hmr").click(); + await expect(page.getByTestId("use-cache-hmr-result")).toHaveText("cached", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + } finally { + await writeUseCacheHmrActions(USE_CACHE_HMR_CACHED); + } + }); + + test("refreshes cached functions when only an imported dependency changes", async ({ + page, + request, + }) => { + await writeFile(USE_CACHE_HMR_DEPENDENCY_FILE, USE_CACHE_HMR_DEPENDENCY_INITIAL); + const importedUpdate = await writeUseCacheHmrActions(USE_CACHE_HMR_IMPORTED, true); + try { + await waitForUseCacheHmrTransform(request, importedUpdate); + await page.goto(`${BASE}/use-cache-hmr`); + await expect(async () => { + await page.locator("#call-use-cache-hmr").click(); + await expect(page.getByTestId("use-cache-hmr-result")).toHaveText("dependency-initial", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + await page.evaluate(() => Reflect.set(window, "__vinextUseCacheDependencyHmr", true)); + + await writeFile(USE_CACHE_HMR_DEPENDENCY_FILE, USE_CACHE_HMR_DEPENDENCY_UPDATED); + await expect(async () => { + await page.locator("#call-use-cache-hmr").click(); + await expect(page.getByTestId("use-cache-hmr-result")).toHaveText("dependency-updated", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + expect(await page.evaluate(() => Reflect.get(window, "__vinextUseCacheDependencyHmr"))).toBe( + true, + ); + } finally { + await writeFile(USE_CACHE_HMR_DEPENDENCY_FILE, USE_CACHE_HMR_DEPENDENCY_INITIAL); + await writeUseCacheHmrActions(USE_CACHE_HMR_CACHED); + } + }); + + test("moves one export between use-cache and use-server ownership without reloading", async ({ + page, + }) => { + await writeUseCacheMixedHmrActions(USE_CACHE_MIXED_HMR_CACHED, true); + try { + await page.goto(`${BASE}/use-cache-mixed-ownership`); + await expect(page.getByTestId("mixed-ownership-label")).toHaveText("cache"); + await expect(async () => { + await page.locator("#call-mixed-builtin").click(); + await expect(page.getByTestId("mixed-builtin-result")).toHaveText("builtin", { + timeout: 2000, + }); + await page.locator("#call-mixed-flexible").click(); + await expect(page.getByTestId("mixed-flexible-result")).toHaveText(/^cached:[0-9.e+-]+$/, { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + await page.evaluate(() => Reflect.set(window, "__vinextMixedOwnershipHmr", true)); + + await writeUseCacheMixedHmrActions(USE_CACHE_MIXED_HMR_SERVER, true); + await expect(page.getByTestId("mixed-ownership-label")).toHaveText("server", { + timeout: 15_000, + }); + expect(await page.evaluate(() => Reflect.get(window, "__vinextMixedOwnershipHmr"))).toBe( + true, + ); + await expect(async () => { + await page.locator("#call-mixed-builtin").click(); + await expect(page.getByTestId("mixed-builtin-result")).toHaveText("builtin", { + timeout: 2000, + }); + await page.locator("#call-mixed-flexible").click(); + await expect(page.getByTestId("mixed-flexible-result")).toHaveText("server", { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + + await writeUseCacheMixedHmrActions(USE_CACHE_MIXED_HMR_CACHED, true); + await expect(page.getByTestId("mixed-ownership-label")).toHaveText("cache", { + timeout: 15_000, + }); + expect(await page.evaluate(() => Reflect.get(window, "__vinextMixedOwnershipHmr"))).toBe( + true, + ); + await expect(async () => { + await page.locator("#call-mixed-builtin").click(); + await expect(page.getByTestId("mixed-builtin-result")).toHaveText("builtin", { + timeout: 2000, + }); + await page.locator("#call-mixed-flexible").click(); + await expect(page.getByTestId("mixed-flexible-result")).toHaveText(/^cached:[0-9.e+-]+$/, { + timeout: 2000, + }); + }).toPass({ timeout: 15_000 }); + } finally { + await writeUseCacheMixedHmrActions(USE_CACHE_MIXED_HMR_CACHED); + } + }); +}); + +test.describe('"use cache" nested cache functions as props', () => { + // Ported from Next.js: test/e2e/app-dir/use-cache-with-server-function-props + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-with-server-function-props/use-cache-with-server-function-props.test.ts + // + // Inline "use cache" functions defined inside a cached component are passed + // as props to a client component and invoked via useActionState. This is a + // full client→server round-trip: the cached functions must serialize as + // server references in the RSC payload AND resolve back on the action POST. + const isoDateRegExp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + const randomRegExp = /^\d+\.\d+$/; + + test("should be able to use nested cache functions as props", async ({ page }) => { + await page.goto(`${BASE}/use-cache-nested-fn-props`); + + // Click + assert inside a polling loop: a click that lands before + // hydration completes falls back to a native form POST (full document + // reload) and loses the useActionState output, so retry until the + // hydrated client-side round-trip succeeds. + await expect(async () => { + await page.locator("#submit-button-date").click(); + await expect(page.locator("#date")).toHaveText(isoDateRegExp, { timeout: 2000 }); + }).toPass({ timeout: 15_000 }); + + await expect(async () => { + await page.locator("#submit-button-random").click(); + await expect(page.locator("#random")).toHaveText(randomRegExp, { timeout: 2000 }); + }).toPass({ timeout: 15_000 }); + + // Closure-captured bound args: getMessage closes over a value from the + // cached component's scope, which the hoist transform turns into a + // `.bind(null, ...)` bound arg on the server reference. Invoking it from + // the client exercises the full flight round-trip for bound args: + // $$bound serialized into the RSC payload → encodeReply on click → + // decrypt + prepend on the server. The trailing numeric suffix is the + // fixture's Math.random() marker, which the production-server test uses + // to pin cached-invoke semantics for the bound path. + await expect(async () => { + await page.locator("#submit-button-message").click(); + await expect(page.locator("#message")).toHaveText( + /^message:closure-captured-bound-arg-vinext:[0-9.e+-]+$/, + { timeout: 2000 }, + ); + }).toPass({ timeout: 15_000 }); + }); +}); + test.describe('"use cache: private"', () => { test("allows reading cookies inside private caches", async ({ request }) => { // Ported from Next.js: test/e2e/app-dir/use-cache-private/use-cache-private.test.ts diff --git a/tests/fixtures/app-basic/app/prerender-cache-life-only/page.tsx b/tests/fixtures/app-basic/app/prerender-cache-life-only/page.tsx index abf7d1c072..ad32ea519c 100644 --- a/tests/fixtures/app-basic/app/prerender-cache-life-only/page.tsx +++ b/tests/fixtures/app-basic/app/prerender-cache-life-only/page.tsx @@ -2,7 +2,7 @@ import { cacheLife } from "next/cache"; -export default function PrerenderCacheLifeOnlyPage() { +export default async function PrerenderCacheLifeOnlyPage() { cacheLife({ revalidate: 1, expire: 3 }); return ( diff --git a/tests/fixtures/app-basic/app/prerender-cache-life/page.tsx b/tests/fixtures/app-basic/app/prerender-cache-life/page.tsx index 6be56c10e5..934d305504 100644 --- a/tests/fixtures/app-basic/app/prerender-cache-life/page.tsx +++ b/tests/fixtures/app-basic/app/prerender-cache-life/page.tsx @@ -2,9 +2,7 @@ import { cacheLife } from "next/cache"; -export const revalidate = 1; - -export default function PrerenderCacheLifePage() { +export default async function PrerenderCacheLifePage() { cacheLife({ revalidate: 1, expire: 3 }); return ( diff --git a/tests/fixtures/app-basic/app/use-cache-client-import/actions.ts b/tests/fixtures/app-basic/app/use-cache-client-import/actions.ts new file mode 100644 index 0000000000..32b47d1e31 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-client-import/actions.ts @@ -0,0 +1,10 @@ +"use cache"; + +export async function getCachedMessage(value: string) { + return `client-cache:${value}:${Math.random()}`; +} + +export async function getUncachedMessage(value: string) { + "use server"; + return `client-server:${value}:${Math.random()}`; +} diff --git a/tests/fixtures/app-basic/app/use-cache-client-import/form.tsx b/tests/fixtures/app-basic/app/use-cache-client-import/form.tsx new file mode 100644 index 0000000000..3cc1381227 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-client-import/form.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { getCachedMessage, getUncachedMessage } from "./actions"; + +export function ClientCacheCaller() { + const [message, setMessage] = useState(""); + const [completedCalls, setCompletedCalls] = useState(0); + + function callCachedMessage(value: string) { + void getCachedMessage(value).then( + (result) => { + setMessage(result); + setCompletedCalls((count) => count + 1); + }, + (error) => { + setMessage(`error:${error instanceof Error ? error.message : String(error)}`); + }, + ); + } + + function callUncachedMessage(value: string) { + void getUncachedMessage(value).then( + (result) => { + setMessage(result); + setCompletedCalls((count) => count + 1); + }, + (error) => { + setMessage(`error:${error instanceof Error ? error.message : String(error)}`); + }, + ); + } + + return ( +
+ + + + {message} + {completedCalls} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-client-import/page.tsx b/tests/fixtures/app-basic/app/use-cache-client-import/page.tsx new file mode 100644 index 0000000000..9907e3a252 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-client-import/page.tsx @@ -0,0 +1,10 @@ +import { ClientCacheCaller } from "./form"; + +export default function Page() { + return ( +
+

Client Imported Cache

+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-hmr/actions.ts b/tests/fixtures/app-basic/app/use-cache-hmr/actions.ts new file mode 100644 index 0000000000..3bfd5ae378 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-hmr/actions.ts @@ -0,0 +1,5 @@ +"use cache"; + +export async function getMode() { + return "cached"; +} diff --git a/tests/fixtures/app-basic/app/use-cache-hmr/client.tsx b/tests/fixtures/app-basic/app/use-cache-hmr/client.tsx new file mode 100644 index 0000000000..27073b6801 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-hmr/client.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useState } from "react"; +import { getMode } from "./actions"; + +export function UseCacheHmrClient() { + const [mode, setMode] = useState(""); + + return ( +
+ + {mode} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-hmr/dependency.ts b/tests/fixtures/app-basic/app/use-cache-hmr/dependency.ts new file mode 100644 index 0000000000..d5a451ecf6 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-hmr/dependency.ts @@ -0,0 +1 @@ +export const dependencyMode = "dependency-initial"; diff --git a/tests/fixtures/app-basic/app/use-cache-hmr/page.tsx b/tests/fixtures/app-basic/app/use-cache-hmr/page.tsx new file mode 100644 index 0000000000..88be68e771 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-hmr/page.tsx @@ -0,0 +1,5 @@ +import { UseCacheHmrClient } from "./client"; + +export default function UseCacheHmrPage() { + return ; +} diff --git a/tests/fixtures/app-basic/app/use-cache-mixed-ownership/actions.ts b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/actions.ts new file mode 100644 index 0000000000..172b10eec9 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/actions.ts @@ -0,0 +1,11 @@ +export const ownershipLabel = "cache"; + +export async function builtinAction() { + "use server"; + return "builtin"; +} + +export async function flexibleAction() { + "use cache"; + return `cached:${Math.random()}`; +} diff --git a/tests/fixtures/app-basic/app/use-cache-mixed-ownership/client.tsx b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/client.tsx new file mode 100644 index 0000000000..ec9840ba0c --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/client.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; + +type ServerFunction = () => Promise; + +export function MixedOwnershipClient({ + builtinAction, + flexibleAction, +}: { + builtinAction: ServerFunction; + flexibleAction: ServerFunction; +}) { + const [builtinResult, setBuiltinResult] = useState(""); + const [builtinCalls, setBuiltinCalls] = useState(0); + const [flexibleResult, setFlexibleResult] = useState(""); + const [flexibleCalls, setFlexibleCalls] = useState(0); + + return ( +
+ + {builtinResult} + {builtinCalls} + + + {flexibleResult} + {flexibleCalls} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-mixed-ownership/page.tsx b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/page.tsx new file mode 100644 index 0000000000..3475ce14bd --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-mixed-ownership/page.tsx @@ -0,0 +1,11 @@ +import { builtinAction, flexibleAction, ownershipLabel } from "./actions"; +import { MixedOwnershipClient } from "./client"; + +export default function UseCacheMixedOwnershipPage() { + return ( +
+ {ownershipLabel} + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-nested-fn-props/form.tsx b/tests/fixtures/app-basic/app/use-cache-nested-fn-props/form.tsx new file mode 100644 index 0000000000..1b98660d66 --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-nested-fn-props/form.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useActionState } from "react"; + +// Ported from Next.js: test/e2e/app-dir/use-cache-with-server-function-props +// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/form.tsx + +export function Form({ + getDate, + getRandom, + getMessage, + idSuffix, +}: { + getDate: () => Promise; + getRandom: () => Promise; + // Closure-capturing cached function — exercises bound-arg serialization. + getMessage: () => Promise; + idSuffix?: string; +}) { + const suffix = idSuffix ? `-${idSuffix}` : ""; + const [date, formAction, isDatePending] = useActionState(getDate, null); + + const [random, buttonAction, isRandomPending] = useActionState(getRandom, null); + + const [message, messageAction, isMessagePending] = useActionState(getMessage, null); + + return ( +
+ {" "} + {" "} + +

{isDatePending ? "loading..." : date}

+

{isRandomPending ? "loading..." : random}

+

{isMessagePending ? "loading..." : message}

+
+ ); +} diff --git a/tests/fixtures/app-basic/app/use-cache-nested-fn-props/page.tsx b/tests/fixtures/app-basic/app/use-cache-nested-fn-props/page.tsx new file mode 100644 index 0000000000..a8b1b51c5b --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-nested-fn-props/page.tsx @@ -0,0 +1,72 @@ +import { connection } from "next/server"; +import { Suspense } from "react"; +import { Form } from "./form"; + +// Ported from Next.js: test/e2e/app-dir/use-cache-with-server-function-props +// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-with-server-function-props/app/nested-cache/page.tsx +// +// Inline "use cache" functions defined inside a cached component are passed +// as props to a client component, which invokes them via useActionState. +// This requires the cached functions to be registered as server references +// (serializable into the RSC payload, resolvable on action POST). + +export default function UseCacheNestedFnPropsPage() { + return ( +
+ Loading...}> + + + + +
+ ); +} + +async function CachedForm({ + capturedScopeValue, + idSuffix, +}: { + capturedScopeValue: string; + idSuffix?: string; +}) { + "use cache"; + + const suffix = idSuffix ? `-${idSuffix}` : ""; + + // Closure-captured by getMessage below. The hoist transform lifts the + // capture into a `.bind(null, capturedScopeValue)` bound argument on the + // server reference. The binding is encrypted before RSC serialization and + // decrypted before the cached wrapper builds its argument-based cache key. + return ( + <> + +
{ + "use cache"; + return new Date().toISOString(); + }} + getRandom={async function getRandom() { + "use cache"; + return Math.random(); + }} + getMessage={async () => { + "use cache"; + // The Math.random() suffix makes cache hits on the closure-BOUND path + // observable: only a cache hit can repeat the suffix, so the + // production-server test can assert that two invocations with the + // same bound arg return the identical cached value (and that a + // different bound arg misses instead of reusing the entry). + return `message:${capturedScopeValue}:${Math.random()}`; + }} + /> + + ); +} + +const Dynamic = async () => { + await connection(); + return

Dynamic

; +}; diff --git a/tests/fixtures/app-basic/app/use-cache-transform-coverage/custom-kind.ts b/tests/fixtures/app-basic/app/use-cache-transform-coverage/custom-kind.ts new file mode 100644 index 0000000000..a72a3b089f --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-transform-coverage/custom-kind.ts @@ -0,0 +1,4 @@ +export async function customKind() { + "use cache: durable-cache"; + return "custom-kind"; +} diff --git a/tests/fixtures/app-basic/app/use-cache-transform-coverage/destructured.ts b/tests/fixtures/app-basic/app/use-cache-transform-coverage/destructured.ts new file mode 100644 index 0000000000..50ad3524fa --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-transform-coverage/destructured.ts @@ -0,0 +1,5 @@ +"use cache"; + +export const { value: destructured } = { + value: async () => "destructured", +}; diff --git a/tests/fixtures/app-basic/app/use-cache-transform-coverage/page.tsx b/tests/fixtures/app-basic/app/use-cache-transform-coverage/page.tsx new file mode 100644 index 0000000000..d34755bdca --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-transform-coverage/page.tsx @@ -0,0 +1,9 @@ +import { destructured } from "./destructured"; +import { fromServerBoundary } from "./server-boundary"; +import { customKind } from "./custom-kind"; + +export default async function UseCacheTransformCoveragePage() { + const values = await Promise.all([destructured(), fromServerBoundary(), customKind()]); + + return {values.join("|")}; +} diff --git a/tests/fixtures/app-basic/app/use-cache-transform-coverage/server-boundary.ts b/tests/fixtures/app-basic/app/use-cache-transform-coverage/server-boundary.ts new file mode 100644 index 0000000000..aefd9fe73e --- /dev/null +++ b/tests/fixtures/app-basic/app/use-cache-transform-coverage/server-boundary.ts @@ -0,0 +1,6 @@ +"use server"; + +export async function fromServerBoundary() { + "use cache"; + return `server-boundary:${Math.random()}`; +} diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d0619f5287..b0a152bec7 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -7273,6 +7273,57 @@ describe('"use cache" runtime', () => { expect(Reflect.get(cached, Symbol.for("vinext.useCacheAcceptsSecondArgument"))).toBe(true); }); + it("excludes arguments beyond the declared arity", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + let calls = 0; + const cached = registerCachedFunction( + async (value: number, ...extra: unknown[]) => { + calls++; + return { value, extra }; + }, + "test:declared-arity", + "", + { argumentCount: 1 }, + ); + + expect(await cached(1, "first")).toEqual({ value: 1, extra: [] }); + expect(await cached(1, "second")).toEqual({ value: 1, extra: [] }); + expect(calls).toBe(1); + }); + + it("excludes framework arguments from zero-arity cached functions", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + let calls = 0; + const cached = registerCachedFunction( + async (...args: unknown[]) => { + calls++; + return args; + }, + "test:zero-arity", + "", + { argumentCount: 0 }, + ); + + expect(await cached("first")).toEqual([]); + expect(await cached("second")).toEqual([]); + expect(calls).toBe(1); + }); + + it("preserves rest arguments when declared arity is unknown", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const cached = registerCachedFunction( + async (...args: unknown[]) => args, + "test:rest-args", + "", + {}, + ); + + expect(await cached(1, 2)).toEqual([1, 2]); + }); + it("falls back to JSON when RSC module is unavailable (test environment)", async () => { // In vitest, @vitejs/plugin-rsc/react/rsc is not available (no Vite RSC // environment). The runtime should gracefully fall back to JSON.stringify diff --git a/tests/use-cache-transform.test.ts b/tests/use-cache-transform.test.ts index 82209bb8d6..d0c8a3239a 100644 --- a/tests/use-cache-transform.test.ts +++ b/tests/use-cache-transform.test.ts @@ -1,103 +1,724 @@ +/** + * Tests the vinext user-land server function directive integration used for function-level + * "use cache" directives. Vinext owns the directive plugin while plugin-rsc + * provides directive transforms and aggregates independently owned server + * reference claims. + */ +import path from "node:path"; +import { createHash } from "node:crypto"; import { describe, expect, it } from "vite-plus/test"; +import { parseAst, type Plugin } from "vite"; import vinext from "../packages/vinext/src/index.js"; +import { APP_FIXTURE_DIR, RSC_ENTRIES } from "./helpers.js"; -function getUseCacheTransform(): (code: string, id: string) => Promise { - const plugin = vinext().find( - (candidate): candidate is Exclude => - typeof candidate === "object" && - candidate !== null && - Reflect.get(candidate, "name") === "vinext:use-cache", - ); - expect(plugin).toBeDefined(); +// oxlint-disable-next-line typescript/no-explicit-any +function unwrapHook(hook: any): ((...args: any[]) => any) | undefined { + return typeof hook === "function" ? hook : hook?.handler; +} + +async function getPlugins(options: { manualRsc?: boolean } = {}): Promise { + // oxlint-disable-next-line typescript/no-explicit-any + const rawPlugins = ( + vinext({ appDir: APP_FIXTURE_DIR, rsc: options.manualRsc ? false : undefined }) as any[] + ).flat(Infinity); + if (options.manualRsc) { + const rsc = (await import("@vitejs/plugin-rsc")).default; + rawPlugins.push(rsc({ entries: RSC_ENTRIES })); + } + const resolved = await Promise.all(rawPlugins.map((plugin) => Promise.resolve(plugin))); + return resolved.flat(Infinity).filter(Boolean) as Plugin[]; +} - const transform = plugin && "transform" in plugin ? plugin.transform : undefined; - const handler = - typeof transform === "object" && transform !== null && "handler" in transform - ? transform.handler - : transform; - expect(typeof handler).toBe("function"); +const moduleId = path.join(APP_FIXTURE_DIR, "app", "unit-test-inline-cache.tsx"); +const inlineCacheCode = [ + `export async function getData() {`, + ` "use cache";`, + ` return 1;`, + `}`, +].join("\n"); +const fileCacheCode = [ + `"use cache";`, + `export async function getData() {`, + ` return 1;`, + `}`, +].join("\n"); - return (code, id) => - (handler as (code: string, id: string) => Promise).call({}, code, id); +async function configurePluginRsc(plugins: Plugin[]) { + const minimal = plugins.find((plugin) => plugin.name === "rsc:minimal")!; + const configResolved = unwrapHook(minimal.configResolved)!; + configResolved.call(minimal, { + root: APP_FIXTURE_DIR, + command: "build", + environments: { + rsc: { build: { outDir: path.join(APP_FIXTURE_DIR, "dist/rsc") } }, + }, + }); + const useCachePlugin = plugins.find( + (plugin) => plugin.name === "vinext:server-function-directives", + )!; + unwrapHook(useCachePlugin.configResolved)!.call(useCachePlugin, { plugins }); + // oxlint-disable-next-line typescript/no-explicit-any + return (minimal as any).api.manager; } -describe('"use cache" transform argument metadata', () => { - // Extends Next.js's cached generateMetadata parent-argument coverage to - // default and rest parameter declarations: - // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache/use-cache.test.ts +async function configureVinext(plugins: Plugin[]) { + const configPlugin = plugins.find((plugin) => plugin.name === "vinext:config")!; + await unwrapHook(configPlugin.config)!.call( + configPlugin, + { root: APP_FIXTURE_DIR }, + { command: "build", mode: "test" }, + ); +} + +async function transformRsc(source: string): Promise { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const result = await unwrapHook(plugin.transform)!.call( + { environment: { name: "rsc", mode: "build" } }, + source, + moduleId, + ); + return result!.code; +} + +describe("plugin-rsc inline use-cache references", () => { + it("supports an explicitly registered RSC plugin", async () => { + const plugins = await getPlugins({ manualRsc: true }); + const manager = await configurePluginRsc(plugins); + const useCacheIndex = plugins.findIndex( + (candidate) => candidate.name === "vinext:server-function-directives", + ); + const useServerIndex = plugins.findIndex((candidate) => candidate.name === "rsc:use-server"); + expect(useCacheIndex).toBeGreaterThanOrEqual(0); + expect(useCacheIndex).toBeLessThan(useServerIndex); + + const transformed = await unwrapHook(plugins[useCacheIndex]!.transform)!.call( + { environment: { name: "rsc", mode: "build" } }, + inlineCacheCode, + moduleId, + ); + expect(transformed!.code).toContain("registerCachedFunction"); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeDefined(); + }); + + it("keeps the vinext claim through the built-in use-server transform", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const useCacheIndex = plugins.findIndex( + (candidate) => candidate.name === "vinext:server-function-directives", + ); + const useServerIndex = plugins.findIndex((candidate) => candidate.name === "rsc:use-server"); + expect(useCacheIndex).toBeLessThan(useServerIndex); + + const context = { environment: { name: "rsc", mode: "build" } }; + const transformed = await unwrapHook(plugins[useCacheIndex]!.transform)!.call( + context, + inlineCacheCode, + moduleId, + ); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeDefined(); + + await unwrapHook(plugins[useServerIndex]!.transform)!.call( + context, + transformed!.code, + moduleId, + ); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeDefined(); + + const ssrContext = { environment: { name: "ssr", mode: "build" } }; + const proxied = await unwrapHook(plugins[useCacheIndex]!.transform)!.call( + ssrContext, + fileCacheCode, + moduleId, + ); + await unwrapHook(plugins[useServerIndex]!.transform)!.call(ssrContext, proxied!.code, moduleId); + expect(manager.serverReferences.metaMap.get(moduleId)).toMatchObject({ + importId: moduleId, + exportNames: expect.arrayContaining(["getData"]), + }); + }); + + it("aggregates use-server and vinext claims", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const useCachePlugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const useServerPlugin = plugins.find((candidate) => candidate.name === "rsc:use-server")!; + const context = { environment: { name: "rsc", mode: "build" } }; + const source = [ + `export async function action() {`, + ` "use server";`, + `}`, + `export async function getData() {`, + ` "use cache";`, + ` return 1;`, + `}`, + ].join("\n"); + + const useCacheResult = await unwrapHook(useCachePlugin.transform)!.call( + context, + source, + moduleId, + ); + const useServerResult = await unwrapHook(useServerPlugin.transform)!.call( + context, + useCacheResult!.code, + moduleId, + ); + expect(useServerResult!.code).toContain("$$VinextReactServer.registerServerReference"); + expect(() => parseAst(useServerResult!.code)).not.toThrow(); + const claims = manager.serverReferences.claimMap.get(moduleId); + expect([...claims.keys()]).toEqual(["vinext:server-function-directives", "rsc:use-server"]); + + const merged = manager.serverReferences.metaMap.get(moduleId)!; + expect(merged.importId).toBe(moduleId); + expect(merged.exportNames).toContainEqual(expect.stringMatching(/action/)); + expect(merged.exportNames).toContainEqual(expect.stringMatching(/getData/)); + expect(merged.exportNames).toHaveLength(new Set(merged.exportNames).size); + }); + + it("removes the vinext claim when the directive is removed", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const useCachePlugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const rscContext = { environment: { name: "rsc", mode: "build" } }; + const ssrContext = { environment: { name: "ssr", mode: "build" } }; + + await unwrapHook(useCachePlugin.transform)!.call(rscContext, fileCacheCode, moduleId); + await unwrapHook(useCachePlugin.transform)!.call(ssrContext, fileCacheCode, moduleId); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeDefined(); + + const source = `export async function getData() { return 1; }`; + await unwrapHook(useCachePlugin.transform)!.call(rscContext, source, moduleId); + await unwrapHook(useCachePlugin.transform)!.call(ssrContext, source, moduleId); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeUndefined(); + }); + + it("hands a file-level reference between vinext and rsc:use-server", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const useCachePlugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const useServerPlugin = plugins.find((candidate) => candidate.name === "rsc:use-server")!; + const context = { environment: { name: "rsc", mode: "build" } }; + const useServerCode = [ + `"use server";`, + `export async function getData() {`, + ` return 1;`, + `}`, + ].join("\n"); + + const transform = async (source: string) => { + const useCacheResult = await unwrapHook(useCachePlugin.transform)!.call( + context, + source, + moduleId, + ); + await unwrapHook(useServerPlugin.transform)!.call( + context, + useCacheResult?.code ?? source, + moduleId, + ); + }; + + await transform(fileCacheCode); + expect([...manager.serverReferences.claimMap.get(moduleId).keys()]).toEqual([ + "vinext:server-function-directives", + ]); + + await transform(useServerCode); + expect([...manager.serverReferences.claimMap.get(moduleId).keys()]).toEqual(["rsc:use-server"]); + + await transform(fileCacheCode); + expect([...manager.serverReferences.claimMap.get(moduleId).keys()]).toEqual([ + "vinext:server-function-directives", + ]); + }); + + it("matches Vite's dev reference key for files outside the project root", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + manager.config.command = "serve"; + manager.server = { + environments: { + rsc: { + config: { root: APP_FIXTURE_DIR }, + moduleGraph: { getModuleById: () => undefined }, + }, + }, + }; + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const externalId = import.meta.filename; + const result = await unwrapHook(plugin.transform)!.call( + { environment: { name: "rsc", mode: "dev" } }, + inlineCacheCode, + externalId, + ); + const expectedKey = path.posix.join("/@fs/", externalId); + expect(result!.code).toContain(JSON.stringify(expectedKey)); + expect(manager.serverReferences.metaMap.get(externalId)!.referenceKey).toBe(expectedKey); + }); + + it("wraps and registers inline cache functions with plugin-rsc's build reference key", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + inlineCacheCode, + moduleId, + ); + expect(result).not.toBeNull(); + + const expectedKey = createHash("sha256") + .update(manager.toRelativeId(moduleId)) + .digest("hex") + .slice(0, 12); + expect(result!.code).toContain("$$VinextReactServer.registerServerReference"); + expect(result!.code).toContain("registerCachedFunction"); + expect(result!.code).toContain(JSON.stringify(expectedKey)); + expect(manager.serverReferences.metaMap.get(moduleId)).toEqual({ + importId: moduleId, + referenceKey: expectedKey, + exportNames: ["$$hoist_0_getData"], + }); + }); + + it("removes its claim when the directive is removed", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const useServerPlugin = plugins.find((candidate) => candidate.name === "rsc:use-server")!; + const context = { environment: { name: "rsc", mode: "build" } }; + await transform.call(context, inlineCacheCode, moduleId); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeDefined(); + const source = `export async function getData() { return 1; }`; + const useServerResult = await unwrapHook(useServerPlugin.transform)!.call( + context, + source, + moduleId, + ); + await transform.call(context, useServerResult?.code ?? source, moduleId); + expect(manager.serverReferences.metaMap.get(moduleId)).toBeUndefined(); + }); + + it("encrypts closure captures through the cache runtime envelope", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const closureCode = [ + `export async function CachedSection() {`, + ` "use cache";`, + ` const capturedSecret = "do-not-leak";`, + ` const getMessage = async () => {`, + ` "use cache";`, + ` return "message:" + capturedSecret;`, + ` };`, + ` return getMessage;`, + `}`, + ].join("\n"); + + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + closureCode, + moduleId, + ); + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /\.bind\(null,\s*\$\$cacheRuntime\.encryptCacheCaptures\(\[capturedSecret\]\)\)/, + ); + expect(result!.code).not.toMatch(/\.bind\(null,\s*capturedSecret\)/); + expect(result!.code).toContain("const [capturedSecret] = $$hoist_encoded"); + const boundRegistration = result!.code.match( + /registerCachedFunction\(\$\$hoist_[^,]+_getMessage\$\$impl,[^)]*\)/, + )?.[0]; + expect(boundRegistration).toBeDefined(); + expect(boundRegistration).toContain('"argumentCount":0'); + }); + + it.each(["ssr", "client"])( + "rejects standalone inline cache functions in the %s graph", + async (environmentName) => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + + await expect( + transform.call( + { environment: { name: environmentName, mode: "build" } }, + inlineCacheCode, + moduleId, + ), + ).rejects.toThrow(/inline "use cache".*Client Component/); + }, + ); + + it("supports destructured file-level exports", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`"use cache";`, `export const { value: getData } = { value: async () => 1 };`].join("\n"), + moduleId, + ); + expect(result!.code).toContain("registerCachedFunction(getData"); + }); + + it("supports named re-exports from file-level cache modules", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`"use cache";`, `export { getData } from "./data";`].join("\n"), + moduleId, + ); + expect(result!.code).toContain("registerCachedFunction($$import_getData"); + }); + + it("accepts configured cache kinds containing punctuation", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`export async function getData() {`, ` "use cache: durable-cache";`, `}`].join("\n"), + moduleId, + ); + expect(result?.code).toContain('"durable-cache"'); + }); + + it("wraps mixed file-level export forms", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + [ + `"use cache";`, + `const imported = async () => 1;`, + `export const direct = async () => 2;`, + `export const alias = imported;`, + `const named = async function named() { return 3; };`, + `export { named, imported as renamed };`, + `export default imported;`, + ].join("\n"), + moduleId, + ); + expect(result!.code).toContain("registerCachedFunction(direct"); + expect(result!.code).toContain("registerCachedFunction(alias"); + expect(result!.code).toContain("registerCachedFunction(named"); + expect(result!.code).toContain("registerCachedFunction(imported"); + expect(manager.serverReferences.metaMap.get(moduleId)!.exportNames).toEqual( + expect.arrayContaining(["direct", "alias", "named", "renamed", "default"]), + ); + }); + + it("rejects statically known synchronous inline cached functions", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + await expect( + transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`export function getData() {`, ` "use cache";`, `}`].join("\n"), + moduleId, + ), + ).rejects.toThrow(/non async function/); + }); + + it.each(["rsc", "ssr", "client"])( + "rejects synchronous exports from file-level cache modules in the %s graph", + async (environmentName) => { + // Ported from Next.js: crates/next-custom-transforms/tests/errors/server-actions/server-graph/14/input.js + // https://github.com/vercel/next.js/blob/canary/crates/next-custom-transforms/tests/errors/server-actions/server-graph/14/input.js + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + await expect( + transform.call( + { environment: { name: environmentName, mode: "build" } }, + [`"use cache";`, `export function getData() { return 1; }`].join("\n"), + moduleId, + ), + ).rejects.toThrow(/non async function/); + }, + ); + + it("rejects primitive exports from file-level cache modules", async () => { + // Ported from Next.js: crates/next-custom-transforms/tests/errors/server-actions/server-graph/14/input.js + // https://github.com/vercel/next.js/blob/canary/crates/next-custom-transforms/tests/errors/server-actions/server-graph/14/input.js + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + await expect( + transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`"use cache";`, `export const value = 1;`].join("\n"), + moduleId, + ), + ).rejects.toThrow(/non async function/); + }); + + it.each(["use cache:remote", "use cache remote", "use cache : remote"])( + "rejects malformed cache directive %s", + async (directive) => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + await expect( + transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`export async function getData() {`, ` ${JSON.stringify(directive)};`, `}`].join("\n"), + moduleId, + ), + ).rejects.toThrow(/Invalid cache directive/); + }, + ); + + it("composes inline cache semantics with a module-level use-server boundary", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const useServerPlugin = plugins.find((candidate) => candidate.name === "rsc:use-server")!; + const context = { environment: { name: "rsc", mode: "build" } }; + const source = [ + `"use server";`, + `export async function getData() {`, + ` "use cache";`, + ` return 1;`, + `}`, + ].join("\n"); + const result = await unwrapHook(plugin.transform)!.call(context, source, moduleId); + expect(result?.code).toContain("registerCachedFunction"); + expect(result?.code).toMatch(/^"use server";\nimport /); + + const useServerResult = await unwrapHook(useServerPlugin.transform)!.call( + context, + result!.code, + moduleId, + ); + expect(useServerResult?.code).toContain("$$VinextReactServer.registerServerReference"); + expect(() => parseAst(useServerResult!.code)).not.toThrow(); + expect([...manager.serverReferences.claimMap.get(moduleId).keys()]).toEqual([ + "vinext:server-function-directives", + "rsc:use-server", + ]); + const exportNames = manager.serverReferences.metaMap.get(moduleId)!.exportNames; + expect(exportNames).toContainEqual(expect.stringMatching(/getData/)); + expect(exportNames).toHaveLength(new Set(exportNames).size); + }); + + it("leaves inline use-server exports to plugin-rsc inside a file-level cache boundary", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const useServerPlugin = plugins.find((candidate) => candidate.name === "rsc:use-server")!; + const context = { environment: { name: "rsc", mode: "build" } }; + const source = [ + `"use cache";`, + `export async function cached() {}`, + `export async function uncached() {`, + ` "use server";`, + `}`, + ].join("\n"); + + const result = await unwrapHook(plugin.transform)!.call(context, source, moduleId); + expect(result?.code).toContain("registerCachedFunction(cached"); + expect(result?.code).not.toContain("registerCachedFunction(uncached"); + expect(result?.code).toMatch(/^"use cache";\nimport /); + + const useServerResult = await unwrapHook(useServerPlugin.transform)!.call( + context, + result!.code, + moduleId, + ); + expect(() => parseAst(useServerResult!.code)).not.toThrow(); + expect([...manager.serverReferences.claimMap.get(moduleId).keys()]).toEqual([ + "vinext:server-function-directives", + "rsc:use-server", + ]); + const exportNames = manager.serverReferences.metaMap.get(moduleId)!.exportNames; + expect(exportNames).toContain("cached"); + expect(exportNames).toContainEqual(expect.stringMatching(/uncached/)); + expect(exportNames).toHaveLength(new Set(exportNames).size); + }); + + it("rejects conflicting file-level cache and use-server directives", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + await expect( + transform.call( + { environment: { name: "rsc", mode: "build" } }, + [`"use server";`, `"use cache";`, `export async function getData() {}`].join("\n"), + moduleId, + ), + ).rejects.toThrow(/cannot contain both/); + }); + + it("returns a source map for transformed modules", async () => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const result = await unwrapHook(plugin.transform)!.call( + { environment: { name: "rsc", mode: "build" } }, + inlineCacheCode, + moduleId, + ); + expect(result?.map).toBeTruthy(); + }); + + it("wraps and registers file-level cache exports in the RSC graph", async () => { + const plugins = await getPlugins(); + const manager = await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: "rsc", mode: "build" } }, + fileCacheCode, + moduleId, + ); + expect(result).not.toBeNull(); + expect(result!.code).toContain("$$VinextReactServer.registerServerReference"); + expect(result!.code).toContain("registerCachedFunction"); + expect(result!.code).toContain('"use cache";'); + expect(manager.serverReferences.metaMap.get(moduleId)!.exportNames).toEqual(["getData"]); + }); + + it("marks file-level App Page default exports after Vinext resolves the app directory", async () => { + const plugins = await getPlugins(); + await configureVinext(plugins); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const pageId = path.join(APP_FIXTURE_DIR, "app", "page.tsx"); + const result = await unwrapHook(plugin.transform)!.call( + { environment: { name: "rsc", mode: "build" } }, + [`"use cache";`, `export default async function Page() { return null; }`].join("\n"), + pageId, + ); + + expect(result?.code).toContain('"appPageDefaultExport":true'); + }); + + it.each(["ssr", "client"])( + "emits server-reference proxies for file-level cache exports in the %s graph", + async (environmentName) => { + const plugins = await getPlugins(); + await configurePluginRsc(plugins); + const plugin = plugins.find( + (candidate) => candidate.name === "vinext:server-function-directives", + )!; + const transform = unwrapHook(plugin.transform)!; + const result = await transform.call( + { environment: { name: environmentName, mode: "build" } }, + fileCacheCode, + moduleId, + ); + expect(result).not.toBeNull(); + expect(result!.code).toContain("createServerReference"); + expect(result!.code).toContain("#getData"); + expect(result!.code).not.toContain("registerCachedFunction"); + expect(result!.code).not.toContain("registerCachedServerReference"); + }, + ); + it.each([ { name: "a default parameter", - source: ` -export async function generateMetadata(_props, parent = fallbackParent) { - "use cache"; - return { title: (await parent).description }; -} -`, + parameters: "_props, parent = fallbackParent", }, { name: "a rest parameter", - source: ` -export async function generateMetadata(...args) { - "use cache"; - return { title: (await args[1]).description }; -} -`, + parameters: "...args", }, ])( - "records that an inline cached function accepts the second argument with $name", - async ({ source }) => { - const transform = getUseCacheTransform(); - const result = (await transform(source, "/app/page.js")) as { code: string }; - - expect(result.code).toContain("{ acceptsSecondArgument: true }"); + "records second-argument usage for an inline cached function with $name", + async ({ parameters }) => { + const code = await transformRsc( + `export async function generateMetadata(${parameters}) {\n "use cache";\n return {};\n}`, + ); + expect(code).toContain('"acceptsSecondArgument":true'); }, ); - it("records file-level cached generateMetadata argument declarations", async () => { - const transform = getUseCacheTransform(); - const result = (await transform( - ` -"use cache"; -export async function generateMetadata(_props, parent = fallbackParent) { - return { title: (await parent).description }; -} -`, - "/app/page.js", - )) as { code: string }; - - expect(result.code).toContain("{ acceptsSecondArgument: true }"); + it("records second-argument usage for file-level cached exports", async () => { + const code = await transformRsc( + `"use cache";\nexport async function generateMetadata(_props, parent = fallbackParent) {\n return {};\n}`, + ); + expect(code).toContain('"acceptsSecondArgument":true'); }); - it("conservatively passes parent to an opaque file-level re-export", async () => { - const transform = getUseCacheTransform(); - const result = (await transform( - ` -"use cache"; -export { generateMetadata } from "./metadata.js"; -`, - "/app/page.js", - )) as { code: string }; - - // Next.js records all arguments as used when a cache export's declaration - // cannot be analyzed in the current module. - expect(result.code).toContain( - 'registerCachedFunction($$import_generateMetadata, "/app/page.js:generateMetadata", "", { acceptsSecondArgument: true })', + it("conservatively records second-argument usage for opaque re-exports", async () => { + const code = await transformRsc( + `"use cache";\nexport { generateMetadata } from "./metadata.js";`, ); + expect(code).toContain('"acceptsSecondArgument":true'); }); - it("records that a cached function without a declared parent omits the second argument", async () => { - const transform = getUseCacheTransform(); - const result = (await transform( - ` -export async function generateMetadata() { - "use cache"; - return { title: "Page" }; -} -`, - "/app/page.js", - )) as { code: string }; - - expect(result.code).toContain("{ acceptsSecondArgument: false }"); + it("records when a cached function omits the second argument", async () => { + const code = await transformRsc( + `export async function generateMetadata() {\n "use cache";\n return {};\n}`, + ); + expect(code).toContain('"acceptsSecondArgument":false'); }); });