From ac2325a5e159b00985ba7deacc999eeab5658b1b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 15:28:51 +0100 Subject: [PATCH 01/12] feat(rsc): generalize server function transforms --- .../plugin-rsc/src/transforms/hoist.test.ts | 82 +++++++++++++++++- packages/plugin-rsc/src/transforms/hoist.ts | 84 +++++++++++++++++-- .../src/transforms/server-action.ts | 58 +++++++++++-- .../src/transforms/wrap-export.test.ts | 28 +++++++ .../plugin-rsc/src/transforms/wrap-export.ts | 84 ++++++++++++++----- 5 files changed, 297 insertions(+), 39 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 3dde6bc91..79518fbcc 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { parseAstAsync } from 'vite' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { transformHoistInlineDirective } from './hoist' import { debugSourceMap } from './test-utils' @@ -466,11 +466,11 @@ export async function kv() { }), ).toMatchInlineSnapshot(` " - export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null]}); + export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null],"hasBoundArgs":false}); - export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"]}); + export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"hasBoundArgs":false}); - export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"]}); + export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"hasBoundArgs":false}); ;async function $$hoist_0_none() { "use cache"; @@ -508,4 +508,78 @@ export async function test() { " `) }) + + it('uses stable names and reports bound arguments', async () => { + const input = ` +async function outer(value) { + return async function cached() { + "use cache"; + return value; + }; +} +` + const ast = await parseAstAsync(input) + const runtime = vi.fn((value: string) => value) + const first = transformHoistInlineDirective(input, ast, { + directive: 'use cache', + stableName: true, + runtime, + }) + const second = transformHoistInlineDirective(input, ast, { + directive: 'use cache', + stableName: true, + runtime: (value) => value, + }) + expect(runtime).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.objectContaining({ hasBoundArgs: true }), + ) + expect(first.names).toEqual(second.names) + expect(first.names[0]).toMatch(/^\$\$hoist_[a-f0-9]{12}_0_cached$/) + }) + + it('supports object and static class methods', async () => { + const input = ` +const object = { + async ["cached"]() { "use cache"; return 1 }, +}; +class Cache { + static async ["cached"]() { "use cache"; return 2 } +} +` + const transformed = await testTransform(input, { + directive: 'use cache', + }) + expect(transformed).toContain('"cached": /* #__PURE__ */') + expect(transformed).toContain('static ["cached"] = /* #__PURE__ */') + await parseAstAsync(transformed!) + }) + + it('rejects instance, private, getter and setter methods', async () => { + for (const input of [ + `class Cache { async cached() { "use cache" } }`, + `class Cache { static async #cached() { "use cache" } }`, + `const cache = { get cached() { "use cache"; return 1 } }`, + `class Cache { static set cached(value) { "use cache" } }`, + ]) { + await expect( + testTransform(input, { directive: 'use cache' }), + ).rejects.toThrow(/not allowed/) + } + }) + + it('supports stateful directive regexes repeatedly', async () => { + const directive = /^use cache(?:: .+)?$/gy + const input = ` +export async function one() { "use cache" } +export async function two() { "use cache: remote" } +` + expect(await testTransform(input, { directive, noExport: true })).toContain( + 'use cache: remote', + ) + expect(await testTransform(input, { directive, noExport: true })).toContain( + 'use cache: remote', + ) + }) }) diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 8d1665a70..cf9e1efb5 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -8,6 +8,7 @@ import type { } from 'estree' import { walk } from 'estree-walker' import MagicString from 'magic-string' +import { hashString } from '../plugins/utils' import { buildScopeTree, type ScopeTree } from './scope' export function transformHoistInlineDirective( @@ -21,13 +22,14 @@ export function transformHoistInlineDirective( runtime: ( value: string, name: string, - meta: { directiveMatch: RegExpMatchArray }, + meta: { directiveMatch: RegExpMatchArray; hasBoundArgs: boolean }, ) => string directive: string | RegExp rejectNonAsyncFunction?: boolean encode?: (value: string) => string decode?: (value: string) => string noExport?: boolean + stableName?: boolean }, ): { output: MagicString @@ -45,6 +47,7 @@ export function transformHoistInlineDirective( const scopeTree = buildScopeTree(ast) const names: string[] = [] + const signatureCounts = new Map() walk(ast, { enter(node, parent) { @@ -65,12 +68,60 @@ export function transformHoistInlineDirective( ) } + const isObjectMethod = + node.type === 'FunctionExpression' && + parent?.type === 'Property' && + (parent.method || parent.kind !== 'init') + const isClassMethod = + node.type === 'FunctionExpression' && + parent?.type === 'MethodDefinition' + if (isClassMethod && !parent.static) { + throw Object.assign( + new Error( + `It is not allowed to define inline ${JSON.stringify(match[0])} annotated class instance methods. Use a function, object method property, or static class method instead.`, + ), + { pos: parent.start }, + ) + } + if (isClassMethod && parent.key.type === 'PrivateIdentifier') { + throw Object.assign( + new Error( + `It is not allowed to define inline ${JSON.stringify(match[0])} annotated private class methods.`, + ), + { pos: parent.start }, + ) + } + if ( + (isObjectMethod && parent.kind !== 'init') || + (isClassMethod && parent.kind !== 'method') + ) { + throw Object.assign( + new Error( + `It is not allowed to define inline ${JSON.stringify(match[0])} annotated getters or setters.`, + ), + { pos: parent.start }, + ) + } + const declName = node.type === 'FunctionDeclaration' && node.id.name + const expressionName = + node.type === 'FunctionExpression' ? node.id?.name : undefined + const methodName = + (isObjectMethod || isClassMethod) && + (parent.key.type === 'Identifier' || parent.key.type === 'Literal') + ? String( + parent.key.type === 'Identifier' + ? parent.key.name + : parent.key.value, + ) + : undefined const originalName = declName || + methodName || (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' && parent.id.name) || + expressionName || 'anonymous_server_function' const bindVars = getBindVars(node, scopeTree) @@ -92,8 +143,15 @@ export function transformHoistInlineDirective( } // append a new `FunctionDeclaration` at the end + let nameKey = String(names.length) + if (options.stableName) { + const signature = `${originalName}:${input.slice(node.start, node.end)}` + const signatureCount = signatureCounts.get(signature) ?? 0 + signatureCounts.set(signature, signatureCount + 1) + nameKey = `${hashString(signature)}_${signatureCount}` + } const newName = - `$$hoist_${names.length}` + (originalName ? `_${originalName}` : '') + `$$hoist_${nameKey}` + (originalName ? `_${originalName}` : '') names.push(newName) output.update( node.start, @@ -113,6 +171,7 @@ export function transformHoistInlineDirective( // replace original declartion with action register + bind let newCode = `/* #__PURE__ */ ${runtime(newName, newName, { directiveMatch: match, + hasBoundArgs: bindVars.length > 0, })}` if (bindVars.length > 0) { const bindArgs = options.encode @@ -120,7 +179,19 @@ export function transformHoistInlineDirective( : bindVars.map((b) => b.expr).join(', ') newCode = `${newCode}.bind(null, ${bindArgs})` } - if (declName) { + if (isObjectMethod) { + output.update( + parent.start, + node.start, + `${input.slice(parent.key.start, parent.key.end)}: `, + ) + } else if (isClassMethod) { + const key = parent.computed + ? `[${input.slice(parent.key.start, parent.key.end)}]` + : input.slice(parent.key.start, parent.key.end) + output.update(parent.start, node.start, `static ${key} = `) + newCode += ';' + } else if (declName) { newCode = `const ${declName} = ${newCode};` if (parent?.type === 'ExportDefaultDeclaration') { output.remove(parent.start, node.start) @@ -132,10 +203,7 @@ export function transformHoistInlineDirective( }, }) - return { - output, - names, - } + return { output, names } } const exactRegex = (s: string): RegExp => @@ -151,7 +219,9 @@ function matchDirective( stmt.expression.type === 'Literal' && typeof stmt.expression.value === 'string' ) { + directive.lastIndex = 0 const match = stmt.expression.value.match(directive) + directive.lastIndex = 0 if (match) { return { match, node: stmt.expression } } diff --git a/packages/plugin-rsc/src/transforms/server-action.ts b/packages/plugin-rsc/src/transforms/server-action.ts index ee853f4a7..7d61d73d5 100644 --- a/packages/plugin-rsc/src/transforms/server-action.ts +++ b/packages/plugin-rsc/src/transforms/server-action.ts @@ -1,8 +1,10 @@ -import type { Program } from 'estree' +import type { Literal, Program } from 'estree' import type MagicString from 'magic-string' import { transformHoistInlineDirective } from './hoist' -import { hasDirective } from './utils' -import { transformWrapExport } from './wrap-export' +import { + transformWrapExport, + type TransformWrapExportOptions, +} from './wrap-export' // TODO // source map for `options.runtime` (registerServerReference) call @@ -12,9 +14,19 @@ export function transformServerActionServer( ast: Program, options: { runtime: (value: string, name: string) => string + directive?: string | RegExp + moduleDirective?: Literal + moduleRuntime?: TransformWrapExportOptions['runtime'] + inlineRuntime?: Parameters< + typeof transformHoistInlineDirective + >[2]['runtime'] + filter?: TransformWrapExportOptions['filter'] rejectNonAsyncFunction?: boolean + rejectNonAsyncModule?: boolean encode?: (value: string) => string decode?: (value: string) => string + stableName?: boolean + preserveModuleDirective?: boolean }, ): | { @@ -25,12 +37,42 @@ export function transformServerActionServer( output: MagicString names: string[] } { - // TODO: unify (generalize transformHoistInlineDirective to support top-level directive cases) - if (hasDirective(ast.body, 'use server')) { - return transformWrapExport(input, ast, options) + const useServerStatement = ast.body.find( + (statement) => + statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + statement.expression.value === 'use server', + ) + const moduleDirective = + options.moduleDirective ?? + (useServerStatement?.type === 'ExpressionStatement' && + useServerStatement.expression.type === 'Literal' + ? useServerStatement.expression + : undefined) + + if (moduleDirective?.type === 'Literal') { + const result = transformWrapExport(input, ast, { + runtime: options.moduleRuntime ?? options.runtime, + filter: options.filter, + rejectNonAsyncFunction: + options.rejectNonAsyncModule ?? options.rejectNonAsyncFunction, + }) + if (!options.preserveModuleDirective && options.moduleDirective) { + result.output.overwrite( + moduleDirective.start, + moduleDirective.end, + `/* ${JSON.stringify(moduleDirective.value)} */`, + ) + } + return result } + return transformHoistInlineDirective(input, ast, { - ...options, - directive: 'use server', + runtime: options.inlineRuntime ?? options.runtime, + directive: options.directive ?? 'use server', + rejectNonAsyncFunction: options.rejectNonAsyncFunction, + encode: options.encode, + decode: options.decode, + stableName: options.stableName, }) } diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index c3cb7473a..1370739e8 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -314,4 +314,32 @@ export default Page; " `) }) + + test('filtered exports are not validated or reported', async () => { + const input = ` +export const revalidate = 1; +export default async function Page() {} +` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + filter: (name) => name !== 'revalidate', + }) + expect(result.exportNames).toEqual(['default']) + expect(result.output.toString()).toContain('export { revalidate };') + }) + + test('unknown identifier exports remain eligible for wrapping', async () => { + const input = ` +const cached = async () => 1; +export default cached; +` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`, + filter: (_name, meta) => meta.isFunction !== false, + }) + expect(result.exportNames).toEqual(['default']) + }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index f5328e967..68aadbe95 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -39,7 +39,15 @@ export function transformWrapExport( end: number, exports: { name: string; meta: ExportMeta }[], ) { - exportNames.push(...exports.map((e) => e.name)) + const filteredExports = exports.map((item) => ({ + ...item, + shouldWrap: filter(item.name, item.meta), + })) + exportNames.push( + ...filteredExports + .filter((item) => item.shouldWrap) + .map((item) => item.name), + ) // update code and move to preserve `registerServerReference` position // e.g. // input @@ -49,9 +57,9 @@ export function transformWrapExport( // async function f() {} // f = registerServerReference(f, ...) << maps to original "export" token // export { f } << - const newCode = exports + const newCode = filteredExports .map((e) => [ - filter(e.name, e.meta) && + e.shouldWrap && `${e.name} = /* #__PURE__ */ ${options.runtime( e.name, e.name, @@ -67,11 +75,11 @@ export function transformWrapExport( } function wrapExport(name: string, exportName: string, meta: ExportMeta = {}) { - exportNames.push(exportName) if (!filter(exportName, meta)) { toAppend.push(`export { ${name} as ${exportName} }`) return } + exportNames.push(exportName) toAppend.push( `const $$wrap_${name} = /* #__PURE__ */ ${options.runtime( @@ -94,20 +102,16 @@ export function transformWrapExport( /** * export function foo() {} */ - validateNonAsyncFunction(options, node.declaration) const name = node.declaration.id.name - wrapSimple(node.start, node.declaration.start, [ - { name, meta: { isFunction: true, declName: name } }, - ]) + const meta = { isFunction: true, declName: name } + if (filter(name, meta)) { + validateNonAsyncFunction(options, node.declaration) + } + wrapSimple(node.start, node.declaration.start, [{ name, meta }]) } else if (node.declaration.type === 'VariableDeclaration') { /** * export const foo = 1, bar = 2 */ - for (const decl of node.declaration.declarations) { - if (decl.init) { - validateNonAsyncFunction(options, decl.init) - } - } if (node.declaration.kind === 'const') { output.update( node.declaration.start, @@ -119,13 +123,34 @@ export function transformWrapExport( extractNames(decl.id), ) // treat only simple single decl as function - let isFunction = false + let isFunction: boolean | undefined if (node.declaration.declarations.length === 1) { const decl = node.declaration.declarations[0]! - isFunction = - decl.id.type === 'Identifier' && - (decl.init?.type === 'ArrowFunctionExpression' || - decl.init?.type === 'FunctionExpression') + if (decl.id.type === 'Identifier') { + if ( + decl.init?.type === 'ArrowFunctionExpression' || + decl.init?.type === 'FunctionExpression' + ) { + isFunction = true + } else if ( + decl.init?.type === 'Literal' || + decl.init?.type === 'ObjectExpression' || + decl.init?.type === 'ArrayExpression' || + decl.init?.type === 'ClassExpression' + ) { + isFunction = false + } + } + } + for (const decl of node.declaration.declarations) { + if ( + decl.init && + extractNames(decl.id).some((name) => + filter(name, { isFunction, declName: name }), + ) + ) { + validateNonAsyncFunction(options, decl.init) + } } wrapSimple( node.start, @@ -198,9 +223,8 @@ export function transformWrapExport( * export default () => {} */ if (node.type === 'ExportDefaultDeclaration') { - validateNonAsyncFunction(options, node.declaration) let localName: string - let isFunction = false + let isFunction: boolean | undefined let declName: string | undefined let defaultExportIdentifierName: string | undefined if ( @@ -219,8 +243,28 @@ export function transformWrapExport( output.update(node.start, node.declaration.start, 'const $$default = ') if (node.declaration.type === 'Identifier') { defaultExportIdentifierName = node.declaration.name + } else if ( + node.declaration.type === 'ArrowFunctionExpression' || + node.declaration.type === 'FunctionExpression' + ) { + isFunction = true + } else if ( + node.declaration.type === 'Literal' || + node.declaration.type === 'ObjectExpression' || + node.declaration.type === 'ArrayExpression' || + node.declaration.type === 'ClassExpression' + ) { + isFunction = false } } + const defaultMeta = { + isFunction, + declName, + defaultExportIdentifierName, + } + if (filter('default', defaultMeta)) { + validateNonAsyncFunction(options, node.declaration) + } wrapExport(localName, 'default', { isFunction, declName, From 142fb07353d2b167559aaa99b49a953951d98d9e Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 15:31:00 +0100 Subject: [PATCH 02/12] feat(rsc): support custom server function directives --- packages/plugin-rsc/src/plugin.ts | 46 ++- .../server-function-directives.test.ts | 269 ++++++++++++++ .../src/plugins/server-function-directives.ts | 328 ++++++++++++++++++ .../plugin-rsc/src/transforms/hoist.test.ts | 3 - .../src/transforms/server-action.ts | 16 +- 5 files changed, 650 insertions(+), 12 deletions(-) create mode 100644 packages/plugin-rsc/src/plugins/server-function-directives.test.ts create mode 100644 packages/plugin-rsc/src/plugins/server-function-directives.ts diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01ec7b083..17dd7d67f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -39,6 +39,12 @@ import { withResolvedIdProxy, } from './plugins/resolved-id-proxy' import { scanBuildStripPlugin } from './plugins/scan' +import { + SERVER_FUNCTION_DIRECTIVE_MARKER, + type ServerFunctionDirective, + type ServerFunctionDirectiveContext, + vitePluginServerFunctionDirectives, +} from './plugins/server-function-directives' import { parseCssVirtual, toCssVirtual, @@ -171,6 +177,8 @@ class RscPluginManager { } export type RscPluginOptions = { + /** @experimental */ + serverFunctionDirectives?: ServerFunctionDirective[] /** * shorthand for configuring `environments.(name).build.rollupOptions.input.index` */ @@ -283,6 +291,8 @@ export type RscPluginOptions = { customClientEntry?: boolean } +export type { ServerFunctionDirective, ServerFunctionDirectiveContext } + export type PluginApi = { manager: RscPluginManager } @@ -339,6 +349,30 @@ export function vitePluginRscMinimal( }, ...vitePluginRscCore(), ...vitePluginUseClient(rscPluginOptions, manager), + ...(rscPluginOptions.serverFunctionDirectives?.length + ? [ + vitePluginServerFunctionDirectives({ + definitions: rscPluginOptions.serverFunctionDirectives, + manager, + serverEnvironmentName: rscPluginOptions.environment?.rsc ?? 'rsc', + browserEnvironmentName: + rscPluginOptions.environment?.browser ?? 'client', + rscRuntime: resolvePackage(`${PKG_NAME}/react/rsc`), + ssrRuntime: resolvePackage(`${PKG_NAME}/react/ssr`), + browserRuntime: resolvePackage(`${PKG_NAME}/react/browser`), + encryptionRuntime: resolvePackage( + `${PKG_NAME}/utils/encryption-runtime`, + ), + expandExportAll: (context, code, ast, id) => + transformExpandExportAll({ + code, + ast, + importer: id, + ...createTransformExpandExportAllContext(context), + }), + }), + ] + : []), ...vitePluginUseServer(rscPluginOptions, manager), ...vitePluginDefineEncryptionKey(rscPluginOptions), { @@ -1946,7 +1980,9 @@ function vitePluginUseServer( // filter: { code: 'use server' }, async handler(code, id) { if (!code.includes('use server')) { - delete manager.serverReferenceMetaMap[id] + if (!code.includes(SERVER_FUNCTION_DIRECTIVE_MARKER)) { + delete manager.serverReferenceMetaMap[id] + } return } let ast = await parseAstAsync(code) @@ -2017,11 +2053,15 @@ function vitePluginUseServer( delete manager.serverReferenceMetaMap[id] return } + const customExportNames = + manager.serverReferenceMetaMap[id]?.exportNames ?? [] manager.serverReferenceMetaMap[id] = { importId: id, referenceKey: getNormalizedId(), - exportNames: - 'names' in result ? result.names : result.exportNames, + exportNames: [ + ...customExportNames, + ...('names' in result ? result.names : result.exportNames), + ], } const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) output.prepend( diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts new file mode 100644 index 000000000..bd8614dba --- /dev/null +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -0,0 +1,269 @@ +import type { Rollup } from 'vite' +import { describe, expect, it, vi } from 'vitest' +import { + SERVER_FUNCTION_DIRECTIVE_MARKER, + vitePluginServerFunctionDirectives, + type ServerFunctionDirective, +} from './server-function-directives' + +type EnvironmentName = 'rsc' | 'ssr' | 'client' + +type Manager = Parameters< + typeof vitePluginServerFunctionDirectives +>[0]['manager'] + +function createHarness( + definitions: ServerFunctionDirective[], + command: 'build' | 'serve' = 'build', +) { + const manager: Manager = { + config: { command }, + server: { environments: {} as Manager['server']['environments'] }, + toRelativeId: (id) => id, + serverReferenceMetaMap: {}, + } + const expandExportAll = vi.fn(async () => undefined) + const plugin = vitePluginServerFunctionDirectives({ + definitions, + manager, + serverEnvironmentName: 'rsc', + browserEnvironmentName: 'client', + encryptionRuntime: '/encryption-runtime.js', + rscRuntime: '/rsc-runtime.js', + browserRuntime: '/browser-runtime.js', + ssrRuntime: '/ssr-runtime.js', + expandExportAll, + }) + const transformHook = plugin.transform + if ( + !transformHook || + typeof transformHook === 'function' || + !('handler' in transformHook) + ) { + throw new Error('expected an object transform hook') + } + const transform = transformHook.handler + + async function run( + code: string, + environment: EnvironmentName = 'rsc', + id = '/src/example.ts', + ) { + const context = { + environment: { + name: environment, + mode: command === 'build' ? 'build' : 'dev', + }, + } as Rollup.TransformPluginContext + const result = await transform.call(context, code, id, { moduleType: 'js' }) + if (!result) return + if (typeof result === 'string') return { code: result, map: null } + return { + code: + typeof result.code === 'string' ? result.code : result.code?.toString(), + map: result.map, + } + } + + return { expandExportAll, manager, run } +} + +function cacheDirective( + overrides: Partial = {}, +): ServerFunctionDirective { + return { + directive: /^use cache(?:: .+)?$/, + test: (code) => code.includes('use cache'), + rejectNonAsyncFunction: true, + wrap: ({ value, directiveMatch, location }) => + `cache(${value}, ${JSON.stringify(directiveMatch[0])}, ${JSON.stringify(location)})`, + ...overrides, + } +} + +describe('vitePluginServerFunctionDirectives', () => { + it('hoists, wraps, and registers inline functions in RSC', async () => { + const { manager, run } = createHarness([cacheDirective()]) + const result = await run(` +export async function getData() { + "use cache"; + return 1; +} +`) + expect(result?.code).toContain(SERVER_FUNCTION_DIRECTIVE_MARKER) + expect(result?.code).toContain('cache($$hoist_') + expect(result?.code).toContain('$$ReactServer.registerServerReference') + expect(result?.code).toContain('/rsc-runtime.js') + expect( + manager.serverReferenceMetaMap['/src/example.ts']?.exportNames, + ).toEqual([expect.stringMatching(/^\$\$hoist_/)]) + }) + + it('encrypts captured values without adding ciphertext to the wrapper', async () => { + const wrap = vi.fn(({ value }: { value: string }) => `cache(${value})`) + const { run } = createHarness([cacheDirective({ wrap })]) + const result = await run(` +export async function outer(value) { + return async function cached() { + "use cache"; + return value; + }; +} +`) + expect(result?.code).toContain('encryptActionBoundArgs([value])') + expect(result?.code).toContain('decryptActionBoundArgs($$encoded)') + expect(result?.code).toContain('/encryption-runtime.js') + expect(wrap).toHaveBeenCalledWith( + expect.objectContaining({ location: 'inline', hasBoundArgs: true }), + ) + }) + + it('wraps module exports and records only selected references', async () => { + const filterExport = vi.fn( + ({ name }: { name: string }) => name !== 'metadata', + ) + const { expandExportAll, manager, run } = createHarness([ + cacheDirective({ filterExport }), + ]) + const result = await run(` +"use cache"; +export async function getData() { return 1 } +export const metadata = { title: "test" }; +`) + expect(expandExportAll).toHaveBeenCalledOnce() + expect(result?.code).toContain('cache(getData, "use cache", "module")') + expect(result?.code).not.toContain('cache(metadata') + expect( + manager.serverReferenceMetaMap['/src/example.ts']?.exportNames, + ).toEqual(['getData']) + expect(filterExport).toHaveBeenCalledWith( + expect.objectContaining({ name: 'metadata', id: '/src/example.ts' }), + ) + }) + + it.each([ + ['client', '/browser-runtime.js'], + ['ssr', '/ssr-runtime.js'], + ] as const)('creates module proxies in %s', async (environment, runtime) => { + const { run } = createHarness([cacheDirective()]) + const result = await run( + `"use cache"; export async function getData() { return 1 }`, + environment, + ) + expect(result?.code).toContain(runtime) + expect(result?.code).toContain('$$ReactClient.createServerReference') + expect(result?.code).toContain('#getData') + }) + + it('uses clientError for non-RSC module boundaries', async () => { + const { run } = createHarness([ + cacheDirective({ + clientError: ({ id, environment }) => `${environment}:${id}`, + }), + ]) + await expect( + run(`"use cache"; export async function getData() {}`, 'client'), + ).rejects.toThrow('client:/src/example.ts') + }) + + it('leaves non-server inline directives untouched', async () => { + const { run } = createHarness([cacheDirective()]) + const code = `export async function getData() { "use cache" }` + await expect(run(code, 'client')).resolves.toBeUndefined() + await expect(run(code, 'ssr')).resolves.toBeUndefined() + }) + + it('wraps inline directives inside use-server modules without owning metadata', async () => { + const { manager, run } = createHarness([cacheDirective()]) + manager.serverReferenceMetaMap['/src/example.ts'] = { + importId: '/src/example.ts', + referenceKey: 'existing', + exportNames: ['action'], + } + const result = await run(` +"use server"; +export async function action() { + async function cached() { "use cache"; return 1 } + return cached(); +} +`) + expect(result?.code).toContain('cache($$hoist_') + expect(result?.code).not.toContain('$$ReactServer.registerServerReference') + expect(manager.serverReferenceMetaMap['/src/example.ts']).toEqual({ + importId: '/src/example.ts', + referenceKey: 'existing', + exportNames: ['action'], + }) + }) + + it('rejects conflicting module-level custom and use-server directives', async () => { + const { run } = createHarness([cacheDirective()]) + await expect( + run(`"use cache"; "use server"; export async function action() {}`), + ).rejects.toThrow('cannot contain both') + }) + + it('runs validation for inline and module directives', async () => { + const validate = vi.fn() + const { run } = createHarness([cacheDirective({ validate })]) + await run(`export async function getData() { "use cache: remote" }`) + await run(`"use cache"; export async function getData() {}`) + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ + directive: 'use cache: remote', + location: 'inline', + }), + ) + expect(validate).toHaveBeenCalledWith( + expect.objectContaining({ directive: 'use cache', location: 'module' }), + ) + }) + + it('rejects synchronous functions when configured', async () => { + const { run } = createHarness([cacheDirective()]) + await expect( + run(`export function getData() { "use cache" }`), + ).rejects.toThrow('non async function') + }) + + it('respects source and id prefilters and clears stale metadata', async () => { + const test = vi.fn(() => false) + const filter = vi.fn(() => false) + const { manager, run } = createHarness([ + cacheDirective({ test }), + cacheDirective({ test: undefined, filter }), + ]) + manager.serverReferenceMetaMap['/src/example.ts'] = { + importId: '/src/example.ts', + referenceKey: 'stale', + exportNames: ['stale'], + } + await expect( + run(`export async function value() { "use cache" }`), + ).resolves.toBeUndefined() + expect(test).toHaveBeenCalled() + expect(filter).toHaveBeenCalled() + expect(manager.serverReferenceMetaMap['/src/example.ts']).toBeUndefined() + }) + + it('rejects overlapping module directive definitions', async () => { + const { run } = createHarness([ + cacheDirective({ directive: /^use cache/ }), + cacheDirective({ directive: 'use cache' }), + ]) + await expect( + run(`"use cache"; export async function getData() {}`, 'client'), + ).rejects.toThrow('Multiple server function directives') + }) + + it('returns source maps for server and proxy transforms', async () => { + const { run } = createHarness([cacheDirective()]) + const server = await run(`export async function getData() { "use cache" }`) + const client = await run( + `"use cache"; export async function getData() {}`, + 'client', + ) + expect(server?.map).toMatchObject({ version: 3 }) + expect(client?.map).toMatchObject({ version: 3 }) + }) +}) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts new file mode 100644 index 000000000..3cfcd333f --- /dev/null +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -0,0 +1,328 @@ +import { exactRegex } from '@rolldown/pluginutils' +import type { Literal, Program } from 'estree' +import type { Plugin, ResolvedConfig, Rollup, ViteDevServer } from 'vite' +import { parseAstAsync } from 'vite' +import { + hasDirective, + transformDirectiveProxyExport, + transformServerActionServer, + type TransformWrapExportFilter, +} from '../transforms' +import { hashString } from './utils' +import { normalizeViteImportAnalysisUrl } from './vite-utils' + +type StringLiteral = Literal & { value: string } + +export const SERVER_FUNCTION_DIRECTIVE_MARKER = + '/* __vite_rsc_server_function_directives__ */' + +export type ServerFunctionDirectiveContext = { + /** Expression passed to `wrap`. */ + value: string + /** Generated reference name for inline functions, or export name for modules. */ + name: string + /** Vite module id containing the directive. */ + id: string + /** Match result for the configured directive string or regular expression. */ + directiveMatch: RegExpMatchArray + /** Whether the directive applies to a function body or an entire module. */ + location: 'inline' | 'module' + /** Whether an inline function closes over values from an outer scope. */ + hasBoundArgs: boolean + /** Export metadata. Only present for module-level directives. */ + meta?: Parameters[1] +} + +export type ServerFunctionDirective = { + /** Exact directive string or regular expression matched against directives. */ + directive: string | RegExp + /** Optional fast source prefilter, evaluated before parsing. */ + test?: (code: string) => boolean + /** Optional module-id filter. */ + filter?: (id: string) => boolean + /** Validates a matched directive before transforming it. */ + validate?: (context: { + id: string + directive: string + location: 'inline' | 'module' + }) => void + /** Reject synchronous annotated functions when enabled. */ + rejectNonAsyncFunction?: boolean + /** Returns the runtime expression used to wrap the server function. */ + wrap: (context: ServerFunctionDirectiveContext) => string + /** Selects which exports a module-level directive wraps and registers. */ + filterExport?: (context: { + name: string + id: string + meta: Parameters[1] + }) => boolean + /** Creates the error shown when a module boundary is imported outside RSC. */ + clientError?: (context: { id: string; environment: string }) => string +} + +type Options = { + definitions: ServerFunctionDirective[] + manager: { + config: Pick + server: Pick + toRelativeId: (id: string) => string + serverReferenceMetaMap: Record< + string, + { importId: string; referenceKey: string; exportNames: string[] } + > + } + serverEnvironmentName: string + browserEnvironmentName: string + encryptionRuntime: string + rscRuntime: string + browserRuntime: string + ssrRuntime: string + expandExportAll: ( + context: Rollup.TransformPluginContext, + code: string, + ast: Program, + id: string, + ) => Promise<{ code: string } | undefined> +} + +function matchDirective(value: string, directive: string | RegExp) { + const pattern = + typeof directive === 'string' + ? exactRegex(directive) + : new RegExp(directive.source, directive.flags) + pattern.lastIndex = 0 + return value.match(pattern) ?? undefined +} + +function isStringLiteral(node: Literal): node is StringLiteral { + return typeof node.value === 'string' +} + +function findModuleDirective( + ast: Program, + directive: string | RegExp, +): StringLiteral | undefined { + const statement = ast.body.find( + (node) => + node.type === 'ExpressionStatement' && + node.expression.type === 'Literal' && + isStringLiteral(node.expression) && + matchDirective(node.expression.value, directive), + ) + if ( + statement?.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + isStringLiteral(statement.expression) + ) { + return statement.expression + } +} + +export function vitePluginServerFunctionDirectives(options: Options): Plugin { + const { definitions, manager } = options + return { + name: 'rsc:server-function-directives', + transform: { + async handler(code, id) { + const active = definitions.filter( + (definition) => + (definition.test?.(code) ?? code.includes('use ')) && + (!definition.filter || definition.filter(id)), + ) + const isServer = this.environment.name === options.serverEnvironmentName + if (active.length === 0) { + if (isServer) delete manager.serverReferenceMetaMap[id] + return + } + + let ast = await parseAstAsync(code) + const useServerBoundary = hasDirective(ast.body, 'use server') + if (!isServer && useServerBoundary) return + + const serverEnvironment = + manager.server.environments[options.serverEnvironmentName] + let normalizedId: string + if (manager.config.command === 'build') { + normalizedId = hashString(manager.toRelativeId(id)) + } else { + if (!serverEnvironment) { + throw new Error( + `Missing ${JSON.stringify(options.serverEnvironmentName)} environment`, + ) + } + normalizedId = normalizeViteImportAnalysisUrl(serverEnvironment, id) + } + + if (!isServer) { + const matches = active.flatMap((definition) => { + const moduleDirective = findModuleDirective( + ast, + definition.directive, + ) + return moduleDirective + ? [[definition, moduleDirective] as const] + : [] + }) + if (matches.length === 0) return + if (matches.length > 1) { + const conflictingMatch = matches[1] + throw Object.assign( + new Error( + 'Multiple server function directives match this module.', + ), + { pos: conflictingMatch?.[1].start }, + ) + } + const match = matches[0] + if (!match) return + const [definition, moduleDirective] = match + if (definition.clientError) { + throw Object.assign( + new Error( + definition.clientError({ + id, + environment: this.environment.name, + }), + ), + { pos: moduleDirective.start }, + ) + } + + const result = transformDirectiveProxyExport(ast, { + code, + directive: moduleDirective.value, + runtime: (name) => + `$$ReactClient.createServerReference(${JSON.stringify(normalizedId + '#' + name)},$$ReactClient.callServer,undefined,${this.environment.mode === 'dev' ? '$$ReactClient.findSourceMapURL' : 'undefined'},${JSON.stringify(name)})`, + }) + if (!result?.output.hasChanged()) return + result.output.prepend( + `import * as $$ReactClient from ${JSON.stringify(this.environment.name === options.browserEnvironmentName ? options.browserRuntime : options.ssrRuntime)};\n`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary', source: id }), + } + } + + const exportNames = new Set() + let needsReactRuntime = false + let needsEncryptionRuntime = false + let outputMap: + | ReturnType< + ReturnType< + typeof transformServerActionServer + >['output']['generateMap'] + > + | undefined + + for (const definition of active) { + let moduleDirective = findModuleDirective(ast, definition.directive) + if (moduleDirective) { + if (useServerBoundary) { + throw Object.assign( + new Error( + `A module cannot contain both ${JSON.stringify(moduleDirective.value)} and "use server" directives.`, + ), + { pos: moduleDirective.start }, + ) + } + const expanded = await options.expandExportAll(this, code, ast, id) + if (expanded) { + code = expanded.code + ast = await parseAstAsync(code) + moduleDirective = findModuleDirective(ast, definition.directive) + } + } + + const moduleMatch = moduleDirective + ? matchDirective(moduleDirective.value, definition.directive) + : undefined + if (moduleMatch) { + definition.validate?.({ + id, + directive: moduleMatch[0], + location: 'module', + }) + } + + const result = transformServerActionServer(code, ast, { + runtime: (value, name) => + `$$ReactServer.registerServerReference(${value}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})`, + directive: definition.directive, + moduleDirective, + moduleRuntime: (value, name, meta) => { + if (!moduleMatch) return value + return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` + }, + inlineRuntime: (value, name, meta) => { + definition.validate?.({ + id, + directive: meta.directiveMatch[0], + location: 'inline', + }) + + const wrapped = definition.wrap({ + value, + name, + id, + directiveMatch: meta.directiveMatch, + location: 'inline', + hasBoundArgs: meta.hasBoundArgs, + }) + + if (useServerBoundary) return wrapped + + needsReactRuntime = true + if (meta.hasBoundArgs) { + needsEncryptionRuntime = true + return `$$ReactServer.registerServerReference((($$wrapped) => async ($$encoded, ...$$args) => $$wrapped(...await __vite_rsc_encryption_runtime.decryptActionBoundArgs($$encoded), ...$$args))(${wrapped}), ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` + } + return `$$ReactServer.registerServerReference(${wrapped}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` + }, + filter: (name, meta) => + definition.filterExport?.({ name, id, meta }) ?? true, + rejectNonAsyncFunction: definition.rejectNonAsyncFunction, + encode: (value) => { + needsEncryptionRuntime = true + return `__vite_rsc_encryption_runtime.encryptActionBoundArgs(${value})` + }, + stableName: true, + detectUseServerModule: false, + }) + if (!result.output.hasChanged()) continue + + const resultExportNames = + 'names' in result ? result.names : result.exportNames + resultExportNames.forEach((name) => exportNames.add(name)) + outputMap = result.output.generateMap({ + hires: 'boundary', + source: id, + }) + code = result.output.toString() + ast = await parseAstAsync(code) + } + + if (!useServerBoundary) { + if (exportNames.size === 0) delete manager.serverReferenceMetaMap[id] + else { + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: normalizedId, + exportNames: [...exportNames], + } + } + } + const imports = [ + needsReactRuntime && + `import * as $$ReactServer from ${JSON.stringify(options.rscRuntime)};`, + needsEncryptionRuntime && + `import * as __vite_rsc_encryption_runtime from ${JSON.stringify(options.encryptionRuntime)};`, + ].filter(Boolean) + return { + code: `${SERVER_FUNCTION_DIRECTIVE_MARKER}\n${imports.join('\n')}\n${code}`, + map: outputMap, + } + }, + }, + } +} diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 79518fbcc..800355906 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -578,8 +578,5 @@ export async function two() { "use cache: remote" } expect(await testTransform(input, { directive, noExport: true })).toContain( 'use cache: remote', ) - expect(await testTransform(input, { directive, noExport: true })).toContain( - 'use cache: remote', - ) }) }) diff --git a/packages/plugin-rsc/src/transforms/server-action.ts b/packages/plugin-rsc/src/transforms/server-action.ts index 7d61d73d5..13a45aab3 100644 --- a/packages/plugin-rsc/src/transforms/server-action.ts +++ b/packages/plugin-rsc/src/transforms/server-action.ts @@ -27,6 +27,7 @@ export function transformServerActionServer( decode?: (value: string) => string stableName?: boolean preserveModuleDirective?: boolean + detectUseServerModule?: boolean }, ): | { @@ -37,12 +38,15 @@ export function transformServerActionServer( output: MagicString names: string[] } { - const useServerStatement = ast.body.find( - (statement) => - statement.type === 'ExpressionStatement' && - statement.expression.type === 'Literal' && - statement.expression.value === 'use server', - ) + const useServerStatement = + options.detectUseServerModule === false + ? undefined + : ast.body.find( + (statement) => + statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + statement.expression.value === 'use server', + ) const moduleDirective = options.moduleDirective ?? (useServerStatement?.type === 'ExpressionStatement' && From 223b1644896b123ceaed98fa649439a6682e3577 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 17:34:37 +0100 Subject: [PATCH 03/12] fix(rsc): validate custom server directives --- .../server-function-directives.test.ts | 136 ++++++++++++++---- .../src/plugins/server-function-directives.ts | 67 +++++++-- packages/plugin-rsc/src/transforms/hoist.ts | 36 +++++ .../src/transforms/server-action.ts | 2 + 4 files changed, 203 insertions(+), 38 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index bd8614dba..be9555fdb 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -1,7 +1,6 @@ import type { Rollup } from 'vite' import { describe, expect, it, vi } from 'vitest' import { - SERVER_FUNCTION_DIRECTIVE_MARKER, vitePluginServerFunctionDirectives, type ServerFunctionDirective, } from './server-function-directives' @@ -75,6 +74,8 @@ function cacheDirective( directive: /^use cache(?:: .+)?$/, test: (code) => code.includes('use cache'), rejectNonAsyncFunction: true, + clientError: ({ id, environment }) => + `inline use cache is not allowed in ${environment}: ${id}`, wrap: ({ value, directiveMatch, location }) => `cache(${value}, ${JSON.stringify(directiveMatch[0])}, ${JSON.stringify(location)})`, ...overrides, @@ -90,10 +91,19 @@ export async function getData() { return 1; } `) - expect(result?.code).toContain(SERVER_FUNCTION_DIRECTIVE_MARKER) - expect(result?.code).toContain('cache($$hoist_') - expect(result?.code).toContain('$$ReactServer.registerServerReference') - expect(result?.code).toContain('/rsc-runtime.js') + expect(result?.code).toMatchInlineSnapshot(` + "/* __vite_rsc_server_function_directives__ */ + import * as $$ReactServer from "/rsc-runtime.js"; + + export const getData = /* #__PURE__ */ $$ReactServer.registerServerReference(cache($$hoist_e9c2205b6101_0_getData, "use cache", "inline"), "53eb073e2100", "$$hoist_e9c2205b6101_0_getData"); + + ;export async function $$hoist_e9c2205b6101_0_getData() { + "use cache"; + return 1; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_e9c2205b6101_0_getData, "name", { value: "getData" }); + " + `) expect( manager.serverReferenceMetaMap['/src/example.ts']?.exportNames, ).toEqual([expect.stringMatching(/^\$\$hoist_/)]) @@ -110,9 +120,22 @@ export async function outer(value) { }; } `) - expect(result?.code).toContain('encryptActionBoundArgs([value])') - expect(result?.code).toContain('decryptActionBoundArgs($$encoded)') - expect(result?.code).toContain('/encryption-runtime.js') + expect(result?.code).toMatchInlineSnapshot(` + "/* __vite_rsc_server_function_directives__ */ + import * as $$ReactServer from "/rsc-runtime.js"; + import * as __vite_rsc_encryption_runtime from "/encryption-runtime.js"; + + export async function outer(value) { + return /* #__PURE__ */ $$ReactServer.registerServerReference((($$wrapped) => async ($$encoded, ...$$args) => $$wrapped(...await __vite_rsc_encryption_runtime.decryptActionBoundArgs($$encoded), ...$$args))(cache($$hoist_ab3ae7af371a_0_cached)), "53eb073e2100", "$$hoist_ab3ae7af371a_0_cached").bind(null, __vite_rsc_encryption_runtime.encryptActionBoundArgs([value])); + } + + ;export async function $$hoist_ab3ae7af371a_0_cached(value) { + "use cache"; + return value; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_ab3ae7af371a_0_cached, "name", { value: "cached" }); + " + `) expect(wrap).toHaveBeenCalledWith( expect.objectContaining({ location: 'inline', hasBoundArgs: true }), ) @@ -131,8 +154,18 @@ export async function getData() { return 1 } export const metadata = { title: "test" }; `) expect(expandExportAll).toHaveBeenCalledOnce() - expect(result?.code).toContain('cache(getData, "use cache", "module")') - expect(result?.code).not.toContain('cache(metadata') + expect(result?.code).toMatchInlineSnapshot(` + "/* __vite_rsc_server_function_directives__ */ + + + /* "use cache" */; + async function getData() { return 1 } + let metadata = { title: "test" }; + getData = /* #__PURE__ */ $$ReactServer.registerServerReference(cache(getData, "use cache", "module"), "53eb073e2100", "getData"); + export { getData }; + export { metadata }; + " + `) expect( manager.serverReferenceMetaMap['/src/example.ts']?.exportNames, ).toEqual(['getData']) @@ -141,36 +174,63 @@ export const metadata = { title: "test" }; ) }) - it.each([ - ['client', '/browser-runtime.js'], - ['ssr', '/ssr-runtime.js'], - ] as const)('creates module proxies in %s', async (environment, runtime) => { + it('creates module proxies in client', async () => { + const { run } = createHarness([cacheDirective()]) + const result = await run( + `"use cache"; export async function getData() { return 1 }`, + 'client', + ) + expect(result?.code).toMatchInlineSnapshot(` + "import * as $$ReactClient from "/browser-runtime.js"; + export const getData = /* #__PURE__ */ $$ReactClient.createServerReference("53eb073e2100#getData",$$ReactClient.callServer,undefined,undefined,"getData"); + " + `) + }) + + it('creates module proxies in SSR', async () => { const { run } = createHarness([cacheDirective()]) const result = await run( `"use cache"; export async function getData() { return 1 }`, - environment, + 'ssr', ) - expect(result?.code).toContain(runtime) - expect(result?.code).toContain('$$ReactClient.createServerReference') - expect(result?.code).toContain('#getData') + expect(result?.code).toMatchInlineSnapshot(` + "import * as $$ReactClient from "/ssr-runtime.js"; + export const getData = /* #__PURE__ */ $$ReactClient.createServerReference("53eb073e2100#getData",$$ReactClient.callServer,undefined,undefined,"getData"); + " + `) }) - it('uses clientError for non-RSC module boundaries', async () => { + it('uses clientError for non-RSC inline directives', async () => { const { run } = createHarness([ cacheDirective({ clientError: ({ id, environment }) => `${environment}:${id}`, }), ]) await expect( - run(`"use cache"; export async function getData() {}`, 'client'), + run(`export async function getData() { "use cache" }`, 'client'), ).rejects.toThrow('client:/src/example.ts') }) - it('leaves non-server inline directives untouched', async () => { + it.each(['client', 'ssr'] as const)( + 'rejects inline directives in %s when clientError is configured', + async (environment) => { + const { run } = createHarness([cacheDirective()]) + const code = `export async function getData() { "use cache" }` + await expect(run(code, environment)).rejects.toThrow( + `inline use cache is not allowed in ${environment}: /src/example.ts`, + ) + }, + ) + + it('leaves non-server inline directives untouched without clientError', async () => { const { run } = createHarness([cacheDirective()]) + const { run: runWithoutError } = createHarness([ + cacheDirective({ clientError: undefined }), + ]) const code = `export async function getData() { "use cache" }` - await expect(run(code, 'client')).resolves.toBeUndefined() - await expect(run(code, 'ssr')).resolves.toBeUndefined() + await expect(run(code, 'client')).rejects.toThrow() + await expect(runWithoutError(code, 'client')).resolves.toBeUndefined() + await expect(runWithoutError(code, 'ssr')).resolves.toBeUndefined() }) it('wraps inline directives inside use-server modules without owning metadata', async () => { @@ -187,8 +247,20 @@ export async function action() { return cached(); } `) - expect(result?.code).toContain('cache($$hoist_') - expect(result?.code).not.toContain('$$ReactServer.registerServerReference') + expect(result?.code).toMatchInlineSnapshot(` + "/* __vite_rsc_server_function_directives__ */ + + + "use server"; + export async function action() { + const cached = /* #__PURE__ */ cache($$hoist_bf311121ee97_0_cached, "use cache", "inline"); + return cached(); + } + + ;export async function $$hoist_bf311121ee97_0_cached() { "use cache"; return 1 }; + /* #__PURE__ */ Object.defineProperty($$hoist_bf311121ee97_0_cached, "name", { value: "cached" }); + " + `) expect(manager.serverReferenceMetaMap['/src/example.ts']).toEqual({ importId: '/src/example.ts', referenceKey: 'existing', @@ -226,6 +298,20 @@ export async function action() { ).rejects.toThrow('non async function') }) + it.each(['this', 'super', 'arguments'] as const)( + 'rejects %s inside inline directive functions', + async (expression) => { + const { run } = createHarness([cacheDirective()]) + const code = + expression === 'super' + ? `class Base { value() {} } class Test extends Base { static async value() { "use cache"; return super.value() } }` + : `export async function getData() { "use cache"; return ${expression} }` + await expect(run(code)).rejects.toThrow( + `"use cache" functions cannot use ${JSON.stringify(expression)}`, + ) + }, + ) + it('respects source and id prefilters and clears stale metadata', async () => { const test = vi.fn(() => false) const filter = vi.fn(() => false) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index 3cfcd333f..aa007d4f9 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -1,5 +1,6 @@ import { exactRegex } from '@rolldown/pluginutils' import type { Literal, Program } from 'estree' +import { walk } from 'estree-walker' import type { Plugin, ResolvedConfig, Rollup, ViteDevServer } from 'vite' import { parseAstAsync } from 'vite' import { @@ -56,7 +57,7 @@ export type ServerFunctionDirective = { id: string meta: Parameters[1] }) => boolean - /** Creates the error shown when a module boundary is imported outside RSC. */ + /** Creates the error shown for inline directives outside RSC. */ clientError?: (context: { id: string; environment: string }) => string } @@ -118,6 +119,39 @@ function findModuleDirective( } } +function findInlineDirective( + ast: Program, + directive: string | RegExp, +): StringLiteral | undefined { + let result: StringLiteral | undefined + walk(ast, { + enter(node) { + if ( + result || + (node.type !== 'FunctionDeclaration' && + node.type !== 'FunctionExpression' && + node.type !== 'ArrowFunctionExpression') || + node.body.type !== 'BlockStatement' + ) { + return + } + for (const statement of node.body.body) { + if ( + statement.type === 'ExpressionStatement' && + statement.expression.type === 'Literal' && + isStringLiteral(statement.expression) && + matchDirective(statement.expression.value, directive) + ) { + result = statement.expression + this.skip() + return + } + } + }, + }) + return result +} + export function vitePluginServerFunctionDirectives(options: Options): Plugin { const { definitions, manager } = options return { @@ -154,6 +188,23 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { } if (!isServer) { + for (const definition of active) { + const inlineDirective = findInlineDirective( + ast, + definition.directive, + ) + if (inlineDirective && definition.clientError) { + throw Object.assign( + new Error( + definition.clientError({ + id, + environment: this.environment.name, + }), + ), + { pos: inlineDirective.start }, + ) + } + } const matches = active.flatMap((definition) => { const moduleDirective = findModuleDirective( ast, @@ -175,18 +226,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { } const match = matches[0] if (!match) return - const [definition, moduleDirective] = match - if (definition.clientError) { - throw Object.assign( - new Error( - definition.clientError({ - id, - environment: this.environment.name, - }), - ), - { pos: moduleDirective.start }, - ) - } + const [, moduleDirective] = match const result = transformDirectiveProxyExport(ast, { code, @@ -288,6 +328,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { }, stableName: true, detectUseServerModule: false, + rejectForbiddenExpressions: true, }) if (!result.output.hasChanged()) continue diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index cf9e1efb5..6278853e4 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -1,6 +1,7 @@ import { tinyassert } from '@hiogawa/utils' import type { Program, + BlockStatement, Literal, Node, MemberExpression, @@ -30,6 +31,7 @@ export function transformHoistInlineDirective( decode?: (value: string) => string noExport?: boolean stableName?: boolean + rejectForbiddenExpressions?: boolean }, ): { output: MagicString @@ -67,6 +69,9 @@ export function transformHoistInlineDirective( }, ) } + if (options.rejectForbiddenExpressions) { + validateForbiddenExpressions(node.body, match[0]) + } const isObjectMethod = node.type === 'FunctionExpression' && @@ -206,6 +211,37 @@ export function transformHoistInlineDirective( return { output, names } } +function validateForbiddenExpressions(body: BlockStatement, directive: string) { + walk(body, { + enter(node) { + if ( + node !== body && + (node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression') + ) { + this.skip() + return + } + const expression = + node.type === 'ThisExpression' + ? 'this' + : node.type === 'Super' + ? 'super' + : node.type === 'Identifier' && node.name === 'arguments' + ? 'arguments' + : undefined + if (expression) { + throw Object.assign( + new Error( + `${JSON.stringify(directive)} functions cannot use ${JSON.stringify(expression)}.`, + ), + { pos: node.start }, + ) + } + }, + }) +} + const exactRegex = (s: string): RegExp => new RegExp('^' + s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + '$') diff --git a/packages/plugin-rsc/src/transforms/server-action.ts b/packages/plugin-rsc/src/transforms/server-action.ts index 13a45aab3..2876f5de6 100644 --- a/packages/plugin-rsc/src/transforms/server-action.ts +++ b/packages/plugin-rsc/src/transforms/server-action.ts @@ -28,6 +28,7 @@ export function transformServerActionServer( stableName?: boolean preserveModuleDirective?: boolean detectUseServerModule?: boolean + rejectForbiddenExpressions?: boolean }, ): | { @@ -78,5 +79,6 @@ export function transformServerActionServer( encode: options.encode, decode: options.decode, stableName: options.stableName, + rejectForbiddenExpressions: options.rejectForbiddenExpressions, }) } From d426ce7430a4877216d8ccb8581971937ae58ebc Mon Sep 17 00:00:00 2001 From: James Date: Thu, 11 Jun 2026 17:39:40 +0100 Subject: [PATCH 04/12] feat(rsc): expose server function parameter metadata --- .../server-function-directives.test.ts | 112 ++++++++++++++++++ .../src/plugins/server-function-directives.ts | 6 +- .../plugin-rsc/src/transforms/hoist.test.ts | 6 +- packages/plugin-rsc/src/transforms/hoist.ts | 13 +- .../plugin-rsc/src/transforms/wrap-export.ts | 72 +++++++++-- 5 files changed, 195 insertions(+), 14 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index be9555fdb..68839ace1 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -82,6 +82,21 @@ function cacheDirective( } } +function parameterWrap( + contexts: Array<{ + name: string + location: string + parameters: { count: number; hasRest: boolean } | undefined + }>, +) { + return cacheDirective({ + wrap: ({ value, name, location, parameters }) => { + contexts.push({ name, location, parameters }) + return `cache(${value})` + }, + }) +} + describe('vitePluginServerFunctionDirectives', () => { it('hoists, wraps, and registers inline functions in RSC', async () => { const { manager, run } = createHarness([cacheDirective()]) @@ -352,4 +367,101 @@ export async function action() { expect(server?.map).toMatchObject({ version: 3 }) expect(client?.map).toMatchObject({ version: 3 }) }) + + it('exposes declared parameter metadata for inline functions', async () => { + const contexts: Parameters[0] = [] + const { run } = createHarness([parameterWrap(contexts)]) + await run(` +export async function getData(value, { offset }, ...rest) { + "use cache"; + return [value, offset, rest]; +} +`) + expect(contexts).toEqual([ + expect.objectContaining({ + location: 'inline', + parameters: { count: 3, hasRest: true }, + }), + ]) + + contexts.length = 0 + await run(` +export async function action() { + "use cache"; + return 1; +} +`) + expect(contexts).toEqual([ + expect.objectContaining({ + parameters: { count: 0, hasRest: false }, + }), + ]) + }) + + it('exposes declared parameter metadata for module exports', async () => { + const contexts: Parameters[0] = [] + const { run } = createHarness([parameterWrap(contexts)]) + await run(` +"use cache"; +export async function direct(value, offset) { return value + offset } +const local = async (value) => value; +export { local }; +export { external } from "./external"; +`) + expect(contexts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'direct', + parameters: { count: 2, hasRest: false }, + }), + expect.objectContaining({ + name: 'local', + parameters: { count: 1, hasRest: false }, + }), + expect.objectContaining({ name: 'external', parameters: undefined }), + ]), + ) + }) + + it('exposes declared parameter metadata for default exports', async () => { + const contexts: Parameters[0] = [] + const { run } = createHarness([parameterWrap(contexts)]) + await run(` +"use cache"; +export default async function Page({ params }, ...rest) { + return [params, rest]; +} +`) + expect(contexts).toEqual([ + expect.objectContaining({ + name: 'default', + parameters: { count: 2, hasRest: true }, + }), + ]) + + contexts.length = 0 + await run(` +"use cache"; +const Page = async ({ params }) => params; +export default Page; +`) + expect(contexts).toEqual([ + expect.objectContaining({ + name: 'default', + parameters: { count: 1, hasRest: false }, + }), + ]) + + contexts.length = 0 + await run(` +"use cache"; +export default createPage(); +`) + expect(contexts).toEqual([ + expect.objectContaining({ + name: 'default', + parameters: undefined, + }), + ]) + }) }) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index aa007d4f9..11d884288 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -7,6 +7,7 @@ import { hasDirective, transformDirectiveProxyExport, transformServerActionServer, + type FunctionParameters, type TransformWrapExportFilter, } from '../transforms' import { hashString } from './utils' @@ -30,6 +31,8 @@ export type ServerFunctionDirectiveContext = { location: 'inline' | 'module' /** Whether an inline function closes over values from an outer scope. */ hasBoundArgs: boolean + /** Declared parameter shape when statically known. */ + parameters?: FunctionParameters /** Export metadata. Only present for module-level directives. */ meta?: Parameters[1] } @@ -292,7 +295,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { moduleDirective, moduleRuntime: (value, name, meta) => { if (!moduleMatch) return value - return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` + return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, parameters: meta.parameters, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` }, inlineRuntime: (value, name, meta) => { definition.validate?.({ @@ -308,6 +311,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { directiveMatch: meta.directiveMatch, location: 'inline', hasBoundArgs: meta.hasBoundArgs, + parameters: meta.parameters, }) if (useServerBoundary) return wrapped diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 800355906..050e8dc82 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -466,11 +466,11 @@ export async function kv() { }), ).toMatchInlineSnapshot(` " - export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null],"hasBoundArgs":false}); + export const none = /* #__PURE__ */ $$register($$hoist_0_none, "", "$$hoist_0_none", {"directiveMatch":["use cache",null],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}}); - export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"hasBoundArgs":false}); + export const fs = /* #__PURE__ */ $$register($$hoist_1_fs, "", "$$hoist_1_fs", {"directiveMatch":["use cache: fs",": fs"],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}}); - export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"hasBoundArgs":false}); + export const kv = /* #__PURE__ */ $$register($$hoist_2_kv, "", "$$hoist_2_kv", {"directiveMatch":["use cache: kv",": kv"],"hasBoundArgs":false,"parameters":{"count":0,"hasRest":false}}); ;async function $$hoist_0_none() { "use cache"; diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 6278853e4..f33431a5e 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -11,6 +11,7 @@ import { walk } from 'estree-walker' import MagicString from 'magic-string' import { hashString } from '../plugins/utils' import { buildScopeTree, type ScopeTree } from './scope' +import type { FunctionParameters } from './wrap-export' export function transformHoistInlineDirective( input: string, @@ -23,7 +24,11 @@ export function transformHoistInlineDirective( runtime: ( value: string, name: string, - meta: { directiveMatch: RegExpMatchArray; hasBoundArgs: boolean }, + meta: { + directiveMatch: RegExpMatchArray + hasBoundArgs: boolean + parameters: FunctionParameters + }, ) => string directive: string | RegExp rejectNonAsyncFunction?: boolean @@ -177,6 +182,12 @@ export function transformHoistInlineDirective( let newCode = `/* #__PURE__ */ ${runtime(newName, newName, { directiveMatch: match, hasBoundArgs: bindVars.length > 0, + parameters: { + count: node.params.length, + hasRest: node.params.some( + (parameter) => parameter.type === 'RestElement', + ), + }, })}` if (bindVars.length > 0) { const bindArgs = options.encode diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 68aadbe95..bf0dd954b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -3,10 +3,16 @@ import type { Program } from 'estree' import MagicString from 'magic-string' import { extractNames, validateNonAsyncFunction } from './utils' -type ExportMeta = { +export type FunctionParameters = { + count: number + hasRest: boolean +} + +export type ExportMeta = { declName?: string isFunction?: boolean defaultExportIdentifierName?: string + parameters?: FunctionParameters } export type TransformWrapExportFilter = ( @@ -33,6 +39,26 @@ export function transformWrapExport( const exportNames: string[] = [] const toAppend: string[] = [] const filter = options.filter ?? (() => true) + const localFunctionParameters = new Map() + + for (const node of ast.body) { + if (node.type === 'FunctionDeclaration' && node.id) { + localFunctionParameters.set(node.id.name, getFunctionParameters(node)) + } else if (node.type === 'VariableDeclaration') { + for (const declaration of node.declarations) { + if ( + declaration.id.type === 'Identifier' && + (declaration.init?.type === 'ArrowFunctionExpression' || + declaration.init?.type === 'FunctionExpression') + ) { + localFunctionParameters.set( + declaration.id.name, + getFunctionParameters(declaration.init), + ) + } + } + } + } function wrapSimple( start: number, @@ -103,7 +129,14 @@ export function transformWrapExport( * export function foo() {} */ const name = node.declaration.id.name - const meta = { isFunction: true, declName: name } + const meta = { + isFunction: node.declaration.type === 'FunctionDeclaration', + declName: name, + parameters: + node.declaration.type === 'FunctionDeclaration' + ? getFunctionParameters(node.declaration) + : undefined, + } if (filter(name, meta)) { validateNonAsyncFunction(options, node.declaration) } @@ -124,6 +157,7 @@ export function transformWrapExport( ) // treat only simple single decl as function let isFunction: boolean | undefined + let parameters: FunctionParameters | undefined if (node.declaration.declarations.length === 1) { const decl = node.declaration.declarations[0]! if (decl.id.type === 'Identifier') { @@ -132,6 +166,7 @@ export function transformWrapExport( decl.init?.type === 'FunctionExpression' ) { isFunction = true + parameters = getFunctionParameters(decl.init) } else if ( decl.init?.type === 'Literal' || decl.init?.type === 'ObjectExpression' || @@ -157,7 +192,7 @@ export function transformWrapExport( node.declaration.start, names.map((name) => ({ name, - meta: { isFunction, declName: name }, + meta: { isFunction, declName: name, parameters }, })), ) } else { @@ -196,7 +231,12 @@ export function transformWrapExport( { pos: spec.exported.start }, ) } - wrapExport(spec.local.name, spec.exported.name) + wrapExport(spec.local.name, spec.exported.name, { + isFunction: localFunctionParameters.has(spec.local.name) + ? true + : undefined, + parameters: localFunctionParameters.get(spec.local.name), + }) } } } @@ -223,6 +263,14 @@ export function transformWrapExport( * export default () => {} */ if (node.type === 'ExportDefaultDeclaration') { + const parameters = + node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'FunctionExpression' || + node.declaration.type === 'ArrowFunctionExpression' + ? getFunctionParameters(node.declaration) + : node.declaration.type === 'Identifier' + ? localFunctionParameters.get(node.declaration.name) + : undefined let localName: string let isFunction: boolean | undefined let declName: string | undefined @@ -261,15 +309,12 @@ export function transformWrapExport( isFunction, declName, defaultExportIdentifierName, + parameters, } if (filter('default', defaultMeta)) { validateNonAsyncFunction(options, node.declaration) } - wrapExport(localName, 'default', { - isFunction, - declName, - defaultExportIdentifierName, - }) + wrapExport(localName, 'default', defaultMeta) } } @@ -279,3 +324,12 @@ export function transformWrapExport( return { exportNames, output } } + +function getFunctionParameters(node: { + params: import('estree').Pattern[] +}): FunctionParameters { + return { + count: node.params.length, + hasRest: node.params.some((parameter) => parameter.type === 'RestElement'), + } +} From e539c588164ef20ad3b4de6b430cb61ba48ff909 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 02:10:21 +0100 Subject: [PATCH 05/12] fix(rsc): avoid dev server access during builds --- .../src/plugins/server-function-directives.test.ts | 8 ++++++++ .../plugin-rsc/src/plugins/server-function-directives.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index 68839ace1..c7053bb2d 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -124,6 +124,14 @@ export async function getData() { ).toEqual([expect.stringMatching(/^\$\$hoist_/)]) }) + it('does not require a dev server during build transforms', async () => { + const { manager, run } = createHarness([cacheDirective()]) + manager.server = undefined as unknown as Manager['server'] + await expect( + run(`export async function getData() { "use cache"; return 1 }`), + ).resolves.toBeDefined() + }) + it('encrypts captured values without adding ciphertext to the wrapper', async () => { const wrap = vi.fn(({ value }: { value: string }) => `cache(${value})`) const { run } = createHarness([cacheDirective({ wrap })]) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index 11d884288..6697979c5 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -176,12 +176,12 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { const useServerBoundary = hasDirective(ast.body, 'use server') if (!isServer && useServerBoundary) return - const serverEnvironment = - manager.server.environments[options.serverEnvironmentName] let normalizedId: string if (manager.config.command === 'build') { normalizedId = hashString(manager.toRelativeId(id)) } else { + const serverEnvironment = + manager.server.environments[options.serverEnvironmentName] if (!serverEnvironment) { throw new Error( `Missing ${JSON.stringify(options.serverEnvironmentName)} environment`, From 35b052ccb87d00fa21d728fd77757a998c9b7690 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Fri, 12 Jun 2026 02:12:43 +0100 Subject: [PATCH 06/12] Apply suggestion from @james-elicx --- packages/plugin-rsc/src/transforms/server-action.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/src/transforms/server-action.ts b/packages/plugin-rsc/src/transforms/server-action.ts index 2876f5de6..d7d375373 100644 --- a/packages/plugin-rsc/src/transforms/server-action.ts +++ b/packages/plugin-rsc/src/transforms/server-action.ts @@ -55,6 +55,7 @@ export function transformServerActionServer( ? useServerStatement.expression : undefined) + // TODO: unify (generalize transformHoistInlineDirective to support top-level directive cases) if (moduleDirective?.type === 'Literal') { const result = transformWrapExport(input, ast, { runtime: options.moduleRuntime ?? options.runtime, From c217ebcea503e4b2d0eca8e6a83e85da32301153 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 02:16:30 +0100 Subject: [PATCH 07/12] feat(rsc): configure module directive async validation --- .../src/plugins/server-function-directives.test.ts | 12 ++++++++++++ .../src/plugins/server-function-directives.ts | 3 +++ 2 files changed, 15 insertions(+) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index c7053bb2d..0b6bc8110 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -321,6 +321,18 @@ export async function action() { ).rejects.toThrow('non async function') }) + it('supports separate async validation for inline and module directives', async () => { + const { run } = createHarness([ + cacheDirective({ rejectNonAsyncModule: false }), + ]) + await expect( + run(`"use cache"; export function Page() { return null }`), + ).resolves.toBeDefined() + await expect( + run(`export function getData() { "use cache"; return null }`), + ).rejects.toThrow('non async function') + }) + it.each(['this', 'super', 'arguments'] as const)( 'rejects %s inside inline directive functions', async (expression) => { diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index 6697979c5..fa457c597 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -52,6 +52,8 @@ export type ServerFunctionDirective = { }) => void /** Reject synchronous annotated functions when enabled. */ rejectNonAsyncFunction?: boolean + /** Overrides synchronous-function validation for module-level directives. */ + rejectNonAsyncModule?: boolean /** Returns the runtime expression used to wrap the server function. */ wrap: (context: ServerFunctionDirectiveContext) => string /** Selects which exports a module-level directive wraps and registers. */ @@ -326,6 +328,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { filter: (name, meta) => definition.filterExport?.({ name, id, meta }) ?? true, rejectNonAsyncFunction: definition.rejectNonAsyncFunction, + rejectNonAsyncModule: definition.rejectNonAsyncModule, encode: (value) => { needsEncryptionRuntime = true return `__vite_rsc_encryption_runtime.encryptActionBoundArgs(${value})` From 8595871fc440198ea5dffb1a1d1f504142f9a098 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 02:19:45 +0100 Subject: [PATCH 08/12] feat(rsc): import custom directive runtimes --- .../server-function-directives.test.ts | 17 +++++++++++++++ .../src/plugins/server-function-directives.ts | 21 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index 0b6bc8110..609804bce 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -333,6 +333,23 @@ export async function action() { ).rejects.toThrow('non async function') }) + it('imports directive runtimes for synchronous wrapper expressions', async () => { + const { run } = createHarness([ + cacheDirective({ + runtime: '/cache-runtime.js', + wrap: ({ value, runtime }) => `${runtime}.cache(${value})`, + }), + ]) + const result = await run(` +export class CacheClass { + static async getData() { "use cache"; return 1 } +} +`) + expect(result?.code).toContain('from "/cache-runtime.js"') + expect(result?.code).toContain('.cache($$hoist_') + expect(result?.code).not.toContain('await import') + }) + it.each(['this', 'super', 'arguments'] as const)( 'rejects %s inside inline directive functions', async (expression) => { diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index fa457c597..8bf78378d 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -33,6 +33,8 @@ export type ServerFunctionDirectiveContext = { hasBoundArgs: boolean /** Declared parameter shape when statically known. */ parameters?: FunctionParameters + /** Imported runtime namespace when `runtime` is configured. */ + runtime?: string /** Export metadata. Only present for module-level directives. */ meta?: Parameters[1] } @@ -54,6 +56,8 @@ export type ServerFunctionDirective = { rejectNonAsyncFunction?: boolean /** Overrides synchronous-function validation for module-level directives. */ rejectNonAsyncModule?: boolean + /** Module imported as a namespace for use by `wrap`. */ + runtime?: string /** Returns the runtime expression used to wrap the server function. */ wrap: (context: ServerFunctionDirectiveContext) => string /** Selects which exports a module-level directive wraps and registers. */ @@ -261,6 +265,14 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { | undefined for (const definition of active) { + const runtimeName = definition.runtime + ? `$$server_function_directive_${hashString(definition.runtime)}` + : undefined + let runtimeUsed = false + const getRuntime = () => { + if (runtimeName) runtimeUsed = true + return runtimeName + } let moduleDirective = findModuleDirective(ast, definition.directive) if (moduleDirective) { if (useServerBoundary) { @@ -297,7 +309,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { moduleDirective, moduleRuntime: (value, name, meta) => { if (!moduleMatch) return value - return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, parameters: meta.parameters, meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` + return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, parameters: meta.parameters, runtime: getRuntime(), meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` }, inlineRuntime: (value, name, meta) => { definition.validate?.({ @@ -314,6 +326,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { location: 'inline', hasBoundArgs: meta.hasBoundArgs, parameters: meta.parameters, + runtime: getRuntime(), }) if (useServerBoundary) return wrapped @@ -339,6 +352,12 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { }) if (!result.output.hasChanged()) continue + if (runtimeUsed && definition.runtime && runtimeName) { + result.output.prepend( + `import * as ${runtimeName} from ${JSON.stringify(definition.runtime)};\n`, + ) + } + const resultExportNames = 'names' in result ? result.names : result.exportNames resultExportNames.forEach((name) => exportNames.add(name)) From 76f1d410514972200b3eea5193d2ec09752438bd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 02:47:03 +0100 Subject: [PATCH 09/12] fix(rsc): expose wrapped directive hoists --- .../server-function-directives.test.ts | 14 ++++--- .../src/plugins/server-function-directives.ts | 1 + packages/plugin-rsc/src/transforms/hoist.ts | 37 +++++++++++++------ .../src/transforms/server-action.ts | 2 + 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index 609804bce..fdebb431b 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -109,14 +109,15 @@ export async function getData() { expect(result?.code).toMatchInlineSnapshot(` "/* __vite_rsc_server_function_directives__ */ import * as $$ReactServer from "/rsc-runtime.js"; + export const $$hoist_e9c2205b6101_0_getData = /* #__PURE__ */ $$ReactServer.registerServerReference(cache($$hoist_e9c2205b6101_0_getData$$impl, "use cache", "inline"), "53eb073e2100", "$$hoist_e9c2205b6101_0_getData"); - export const getData = /* #__PURE__ */ $$ReactServer.registerServerReference(cache($$hoist_e9c2205b6101_0_getData, "use cache", "inline"), "53eb073e2100", "$$hoist_e9c2205b6101_0_getData"); + export const getData = $$hoist_e9c2205b6101_0_getData; - ;export async function $$hoist_e9c2205b6101_0_getData() { + ;async function $$hoist_e9c2205b6101_0_getData$$impl() { "use cache"; return 1; }; - /* #__PURE__ */ Object.defineProperty($$hoist_e9c2205b6101_0_getData, "name", { value: "getData" }); + /* #__PURE__ */ Object.defineProperty($$hoist_e9c2205b6101_0_getData$$impl, "name", { value: "getData" }); " `) expect( @@ -147,16 +148,17 @@ export async function outer(value) { "/* __vite_rsc_server_function_directives__ */ import * as $$ReactServer from "/rsc-runtime.js"; import * as __vite_rsc_encryption_runtime from "/encryption-runtime.js"; + export const $$hoist_ab3ae7af371a_0_cached = /* #__PURE__ */ $$ReactServer.registerServerReference((($$wrapped) => async ($$encoded, ...$$args) => $$wrapped(...await __vite_rsc_encryption_runtime.decryptActionBoundArgs($$encoded), ...$$args))(cache($$hoist_ab3ae7af371a_0_cached$$impl)), "53eb073e2100", "$$hoist_ab3ae7af371a_0_cached"); export async function outer(value) { - return /* #__PURE__ */ $$ReactServer.registerServerReference((($$wrapped) => async ($$encoded, ...$$args) => $$wrapped(...await __vite_rsc_encryption_runtime.decryptActionBoundArgs($$encoded), ...$$args))(cache($$hoist_ab3ae7af371a_0_cached)), "53eb073e2100", "$$hoist_ab3ae7af371a_0_cached").bind(null, __vite_rsc_encryption_runtime.encryptActionBoundArgs([value])); + return $$hoist_ab3ae7af371a_0_cached.bind(null, __vite_rsc_encryption_runtime.encryptActionBoundArgs([value])); } - ;export async function $$hoist_ab3ae7af371a_0_cached(value) { + ;async function $$hoist_ab3ae7af371a_0_cached$$impl(value) { "use cache"; return value; }; - /* #__PURE__ */ Object.defineProperty($$hoist_ab3ae7af371a_0_cached, "name", { value: "cached" }); + /* #__PURE__ */ Object.defineProperty($$hoist_ab3ae7af371a_0_cached$$impl, "name", { value: "cached" }); " `) expect(wrap).toHaveBeenCalledWith( diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index 8bf78378d..f8cdbf4cd 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -347,6 +347,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { return `__vite_rsc_encryption_runtime.encryptActionBoundArgs(${value})` }, stableName: true, + exportWrappedHoist: !useServerBoundary, detectUseServerModule: false, rejectForbiddenExpressions: true, }) diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index f33431a5e..97efe8bb6 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -35,6 +35,7 @@ export function transformHoistInlineDirective( encode?: (value: string) => string decode?: (value: string) => string noExport?: boolean + exportWrappedHoist?: boolean stableName?: boolean rejectForbiddenExpressions?: boolean }, @@ -163,32 +164,44 @@ export function transformHoistInlineDirective( const newName = `$$hoist_${nameKey}` + (originalName ? `_${originalName}` : '') names.push(newName) + const implementationName = options.exportWrappedHoist + ? `${newName}$$impl` + : newName output.update( node.start, node.body.start, - `\n;${options.noExport ? '' : 'export '}${ + `\n;${options.noExport || options.exportWrappedHoist ? '' : 'export '}${ node.async ? 'async ' : '' - }function${node.generator ? '*' : ''} ${newName}(${newParams}) `, + }function${node.generator ? '*' : ''} ${implementationName}(${newParams}) `, ) output.appendLeft( node.end, - `;\n/* #__PURE__ */ Object.defineProperty(${newName}, "name", { value: ${JSON.stringify( + `;\n/* #__PURE__ */ Object.defineProperty(${implementationName}, "name", { value: ${JSON.stringify( originalName, )} });\n`, ) output.move(node.start, node.end, input.length) // replace original declartion with action register + bind - let newCode = `/* #__PURE__ */ ${runtime(newName, newName, { - directiveMatch: match, - hasBoundArgs: bindVars.length > 0, - parameters: { - count: node.params.length, - hasRest: node.params.some( - (parameter) => parameter.type === 'RestElement', - ), + let wrappedCode = `/* #__PURE__ */ ${runtime( + implementationName, + newName, + { + directiveMatch: match, + hasBoundArgs: bindVars.length > 0, + parameters: { + count: node.params.length, + hasRest: node.params.some( + (parameter) => parameter.type === 'RestElement', + ), + }, }, - })}` + )}` + let newCode = wrappedCode + if (options.exportWrappedHoist) { + output.prepend(`export const ${newName} = ${wrappedCode};\n`) + newCode = newName + } if (bindVars.length > 0) { const bindArgs = options.encode ? options.encode('[' + bindVars.map((b) => b.expr).join(', ') + ']') diff --git a/packages/plugin-rsc/src/transforms/server-action.ts b/packages/plugin-rsc/src/transforms/server-action.ts index d7d375373..3d6eee4f6 100644 --- a/packages/plugin-rsc/src/transforms/server-action.ts +++ b/packages/plugin-rsc/src/transforms/server-action.ts @@ -26,6 +26,7 @@ export function transformServerActionServer( encode?: (value: string) => string decode?: (value: string) => string stableName?: boolean + exportWrappedHoist?: boolean preserveModuleDirective?: boolean detectUseServerModule?: boolean rejectForbiddenExpressions?: boolean @@ -80,6 +81,7 @@ export function transformServerActionServer( encode: options.encode, decode: options.decode, stableName: options.stableName, + exportWrappedHoist: options.exportWrappedHoist, rejectForbiddenExpressions: options.rejectForbiddenExpressions, }) } From 2ae42d1221f5eacaa89eec7e4d64fdaf39324149 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 02:47:06 +0100 Subject: [PATCH 10/12] feat(rsc): preserve decoded server references --- packages/plugin-rsc/src/core/rsc.test.ts | 37 ++++++++++++++++++++++++ packages/plugin-rsc/src/core/rsc.ts | 35 ++++++++++++++++++++-- packages/plugin-rsc/src/core/shared.ts | 2 ++ packages/plugin-rsc/src/react/rsc.ts | 12 ++++++-- 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-rsc/src/core/rsc.test.ts diff --git a/packages/plugin-rsc/src/core/rsc.test.ts b/packages/plugin-rsc/src/core/rsc.test.ts new file mode 100644 index 000000000..8f2eb55a7 --- /dev/null +++ b/packages/plugin-rsc/src/core/rsc.test.ts @@ -0,0 +1,37 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@vitejs/plugin-rsc/vendor/react-server-dom/server.edge', () => ({ + registerClientReference: vi.fn(), + registerServerReference(reference: Function, id: string, name: string) { + return Object.defineProperties(reference, { + $$typeof: { value: Symbol.for('react.server.reference') }, + $$id: { value: `${id}#${name}` }, + $$bound: { value: null, writable: true }, + }) + }, +})) + +const { createServerManifest, setRequireModule } = await import('./rsc') + +beforeAll(() => { + setRequireModule({ + load() { + throw new Error('preserved references must not load their implementation') + }, + }) +}) + +describe('createServerManifest', () => { + it('preserves server references without loading their implementation', async () => { + const manifest = createServerManifest({ preserveServerReferences: true }) + const entry = manifest['module-id#action']! + expect(entry.id).toContain('$$decode-server-reference:module-id') + + const module = await (globalThis as any).__vite_rsc_require__(entry.id) + expect(Object.prototype.hasOwnProperty.call(module, 'action')).toBe(true) + const reference = module.action + expect(reference.$$typeof).toBe(Symbol.for('react.server.reference')) + expect(reference.$$id).toBe('module-id#action') + expect(reference.$$bound).toBeNull() + }) +}) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index ecd46ea47..ad74b3a66 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -2,6 +2,7 @@ import { memoize, tinyassert } from '@hiogawa/utils' import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' import { SERVER_DECODE_CLIENT_PREFIX, + SERVER_DECODE_REFERENCE_PREFIX, SERVER_REFERENCE_PREFIX, createReferenceCacheTag, removeReferenceCacheTag, @@ -27,6 +28,28 @@ export function setRequireModule(options: { // need memoize to return stable promise from __webpack_require__ ;(globalThis as any).__vite_rsc_server_require__ = memoize( async (id: string) => { + if (id.startsWith(SERVER_DECODE_REFERENCE_PREFIX)) { + id = id.slice(SERVER_DECODE_REFERENCE_PREFIX.length) + id = removeReferenceCacheTag(id) + const target = {} as Record + return new Proxy(target, { + getOwnPropertyDescriptor(_target, name) { + if (typeof name !== 'string' || name === 'then') { + return Reflect.getOwnPropertyDescriptor(target, name) + } + target[name] ??= ReactServer.registerServerReference( + () => { + throw new Error( + `Unexpectedly preserved server reference '${id}#${name}' is called on server`, + ) + }, + id, + name, + ) + return Reflect.getOwnPropertyDescriptor(target, name) + }, + }) + } if (id.startsWith(SERVER_DECODE_CLIENT_PREFIX)) { // decode client reference on the server id = id.slice(SERVER_DECODE_CLIENT_PREFIX.length) @@ -71,7 +94,9 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(): BundlerConfig { +export function createServerManifest(options?: { + preserveServerReferences?: boolean +}): BundlerConfig { const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' return new Proxy( @@ -83,7 +108,13 @@ export function createServerManifest(): BundlerConfig { tinyassert(id) tinyassert(name) return { - id: SERVER_REFERENCE_PREFIX + id + cacheTag, + id: + SERVER_REFERENCE_PREFIX + + (options?.preserveServerReferences + ? SERVER_DECODE_REFERENCE_PREFIX + : '') + + id + + cacheTag, name, chunks: [], async: true, diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts index bd3d18af3..937455395 100644 --- a/packages/plugin-rsc/src/core/shared.ts +++ b/packages/plugin-rsc/src/core/shared.ts @@ -3,6 +3,8 @@ export const SERVER_REFERENCE_PREFIX = '$$server:' export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' +export const SERVER_DECODE_REFERENCE_PREFIX = '$$decode-server-reference:' + // cache bust memoized require promise during dev export function createReferenceCacheTag(): string { const cache = Math.random().toString(36).slice(2) diff --git a/packages/plugin-rsc/src/react/rsc.ts b/packages/plugin-rsc/src/react/rsc.ts index 117fe15ec..33aaca289 100644 --- a/packages/plugin-rsc/src/react/rsc.ts +++ b/packages/plugin-rsc/src/react/rsc.ts @@ -40,16 +40,22 @@ export function renderToReadableStream( export function createFromReadableStream( stream: ReadableStream, - options: CreateFromReadableStreamEdgeOptions = {}, + options: CreateFromReadableStreamEdgeOptions & { + /** Preserve decoded server references so the value can be rendered again. */ + serverReferences?: 'resolve' | 'preserve' + } = {}, ): Promise { + const { serverReferences = 'resolve', ...reactOptions } = options return ReactClient.createFromReadableStream(stream, { serverConsumerManifest: { // https://github.com/facebook/react/pull/31300 // https://github.com/vercel/next.js/pull/71527 - serverModuleMap: createServerManifest(), + serverModuleMap: createServerManifest({ + preserveServerReferences: serverReferences === 'preserve', + }), moduleMap: createServerDecodeClientManifest(), }, - ...options, + ...reactOptions, }) } From 06e5841675d183950a3314bc92cd8f933cac4442 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 03:18:46 +0100 Subject: [PATCH 11/12] fix(rsc): preserve custom directive references --- .../server-function-directives.test.ts | 43 +++++++++++++++++-- .../src/plugins/server-function-directives.ts | 10 ++++- packages/plugin-rsc/src/transforms/hoist.ts | 10 ++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts index fdebb431b..8918fb3ec 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.test.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.test.ts @@ -1,6 +1,7 @@ import type { Rollup } from 'vite' import { describe, expect, it, vi } from 'vitest' import { + SERVER_FUNCTION_DIRECTIVE_MARKER, vitePluginServerFunctionDirectives, type ServerFunctionDirective, } from './server-function-directives' @@ -181,7 +182,7 @@ export const metadata = { title: "test" }; expect(expandExportAll).toHaveBeenCalledOnce() expect(result?.code).toMatchInlineSnapshot(` "/* __vite_rsc_server_function_directives__ */ - + import * as $$ReactServer from "/rsc-runtime.js"; /* "use cache" */; async function getData() { return 1 } @@ -199,17 +200,38 @@ export const metadata = { title: "test" }; ) }) - it('creates module proxies in client', async () => { + it('ignores jsxDEV source metadata while rejecting user this expressions', async () => { const { run } = createHarness([cacheDirective()]) + await expect( + run(` +export async function Page() { + "use cache"; + return _jsxDEV("p", { children: "test" }, void 0, false, { fileName: "page.tsx" }, this); +} +`), + ).resolves.toBeDefined() + await expect( + run(`export async function getData() { "use cache"; return this.value }`), + ).rejects.toThrow('"use cache" functions cannot use "this"') + }) + + it('creates module proxies in client', async () => { + const { manager, run } = createHarness([cacheDirective()]) const result = await run( `"use cache"; export async function getData() { return 1 }`, 'client', ) expect(result?.code).toMatchInlineSnapshot(` - "import * as $$ReactClient from "/browser-runtime.js"; + "/* __vite_rsc_server_function_directives__ */ + import * as $$ReactClient from "/browser-runtime.js"; export const getData = /* #__PURE__ */ $$ReactClient.createServerReference("53eb073e2100#getData",$$ReactClient.callServer,undefined,undefined,"getData"); " `) + expect(manager.serverReferenceMetaMap['/src/example.ts']).toEqual({ + importId: '/src/example.ts', + referenceKey: '53eb073e2100', + exportNames: ['getData'], + }) }) it('creates module proxies in SSR', async () => { @@ -219,7 +241,8 @@ export const metadata = { title: "test" }; 'ssr', ) expect(result?.code).toMatchInlineSnapshot(` - "import * as $$ReactClient from "/ssr-runtime.js"; + "/* __vite_rsc_server_function_directives__ */ + import * as $$ReactClient from "/ssr-runtime.js"; export const getData = /* #__PURE__ */ $$ReactClient.createServerReference("53eb073e2100#getData",$$ReactClient.callServer,undefined,undefined,"getData"); " `) @@ -386,6 +409,18 @@ export class CacheClass { expect(manager.serverReferenceMetaMap['/src/example.ts']).toBeUndefined() }) + it('preserves metadata when transformed output is processed again', async () => { + const { manager, run } = createHarness([cacheDirective()]) + const result = await run( + `"use cache"; export async function getData() { return 1 }`, + ) + expect(result?.code).toContain(SERVER_FUNCTION_DIRECTIVE_MARKER) + const metadata = manager.serverReferenceMetaMap['/src/example.ts'] + + await expect(run(result!.code!)).resolves.toBeUndefined() + expect(manager.serverReferenceMetaMap['/src/example.ts']).toEqual(metadata) + }) + it('rejects overlapping module directive definitions', async () => { const { run } = createHarness([ cacheDirective({ directive: /^use cache/ }), diff --git a/packages/plugin-rsc/src/plugins/server-function-directives.ts b/packages/plugin-rsc/src/plugins/server-function-directives.ts index f8cdbf4cd..6e95ed44f 100644 --- a/packages/plugin-rsc/src/plugins/server-function-directives.ts +++ b/packages/plugin-rsc/src/plugins/server-function-directives.ts @@ -167,6 +167,8 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { name: 'rsc:server-function-directives', transform: { async handler(code, id) { + if (code.includes(SERVER_FUNCTION_DIRECTIVE_MARKER)) return + const active = definitions.filter( (definition) => (definition.test?.(code) ?? code.includes('use ')) && @@ -244,8 +246,13 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { `$$ReactClient.createServerReference(${JSON.stringify(normalizedId + '#' + name)},$$ReactClient.callServer,undefined,${this.environment.mode === 'dev' ? '$$ReactClient.findSourceMapURL' : 'undefined'},${JSON.stringify(name)})`, }) if (!result?.output.hasChanged()) return + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: normalizedId, + exportNames: result.exportNames, + } result.output.prepend( - `import * as $$ReactClient from ${JSON.stringify(this.environment.name === options.browserEnvironmentName ? options.browserRuntime : options.ssrRuntime)};\n`, + `${SERVER_FUNCTION_DIRECTIVE_MARKER}\nimport * as $$ReactClient from ${JSON.stringify(this.environment.name === options.browserEnvironmentName ? options.browserRuntime : options.ssrRuntime)};\n`, ) return { code: result.output.toString(), @@ -309,6 +316,7 @@ export function vitePluginServerFunctionDirectives(options: Options): Plugin { moduleDirective, moduleRuntime: (value, name, meta) => { if (!moduleMatch) return value + needsReactRuntime = true return `$$ReactServer.registerServerReference(${definition.wrap({ value, name, id, directiveMatch: moduleMatch, location: 'module', hasBoundArgs: false, parameters: meta.parameters, runtime: getRuntime(), meta })}, ${JSON.stringify(normalizedId)}, ${JSON.stringify(name)})` }, inlineRuntime: (value, name, meta) => { diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 97efe8bb6..4da710d1c 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -237,7 +237,7 @@ export function transformHoistInlineDirective( function validateForbiddenExpressions(body: BlockStatement, directive: string) { walk(body, { - enter(node) { + enter(node, parent) { if ( node !== body && (node.type === 'FunctionDeclaration' || @@ -246,8 +246,14 @@ function validateForbiddenExpressions(body: BlockStatement, directive: string) { this.skip() return } + const isJsxDevSourceThis = + node.type === 'ThisExpression' && + parent?.type === 'CallExpression' && + parent.arguments.at(-1) === node && + parent.callee.type === 'Identifier' && + /(?:^|_)jsxDEV$/.test(parent.callee.name) const expression = - node.type === 'ThisExpression' + node.type === 'ThisExpression' && !isJsxDevSourceThis ? 'this' : node.type === 'Super' ? 'super' From 82d2c5784b9b9c3b1ecead1d450afedc97186962 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 03:32:39 +0100 Subject: [PATCH 12/12] fix(rsc): deduplicate server reference exports --- packages/plugin-rsc/src/plugin.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 17dd7d67f..62cbb98ef 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -2055,13 +2055,12 @@ function vitePluginUseServer( } const customExportNames = manager.serverReferenceMetaMap[id]?.exportNames ?? [] + const exportNames = + 'names' in result ? result.names : result.exportNames manager.serverReferenceMetaMap[id] = { importId: id, referenceKey: getNormalizedId(), - exportNames: [ - ...customExportNames, - ...('names' in result ? result.names : result.exportNames), - ], + exportNames: [...new Set([...customExportNames, ...exportNames])], } const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) output.prepend( @@ -2134,7 +2133,7 @@ function vitePluginUseServer( for (const meta of Object.values(manager.serverReferenceMetaMap)) { const key = JSON.stringify(meta.referenceKey) const id = JSON.stringify(meta.importId) - const exports = meta.exportNames + const exports = [...new Set(meta.exportNames)] .map((name) => (name === 'default' ? 'default: _default' : name)) .sort() code += `