From 5229ef9588df17cf4d46e516ef83c7d9b74ae504 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:16:24 +0900 Subject: [PATCH 01/24] feat(rsc): allow proxy export filter by node ast Co-authored-by: OpenCode --- .../callable-cache-plugin.ts | 3 + .../file-directive-from-client/action.ts | 3 + .../src/transforms/module-export-scan.test.ts | 2 +- .../src/transforms/module-export-scan.ts | 12 +- .../src/transforms/proxy-export.test.ts | 63 ++++++- .../plugin-rsc/src/transforms/proxy-export.ts | 158 +++++++++--------- .../src/transforms/wrap-export.test.ts | 12 ++ .../plugin-rsc/src/transforms/wrap-export.ts | 10 +- 8 files changed, 172 insertions(+), 91 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts b/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts index a9f4728f7..8a1f0b624 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts @@ -74,6 +74,9 @@ export function callableCachePlugin(): Plugin { const result = transformDirectiveProxyExport(ast, { code, directive, + filter: (_name, meta) => + meta.valueNode?.type !== 'ObjectExpression' && + meta.valueNode?.type !== 'ArrayExpression', rejectNonAsyncFunction: true, runtime: (name) => `$$ReactClient.createServerReference(` + diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index 90dc25108..ea376cff8 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -2,6 +2,9 @@ import { state } from './state' +export const metadata = { title: 'cached metadata' } +export const tags = ['cache'] + export async function cachedFromClient(formData: FormData) { const argument = String(formData.get('argument')) state.executionCount++ diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts index 054dbb193..05a2f19ed 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts @@ -72,7 +72,7 @@ export * from './all' meta: { declName: 'item', isFunction: undefined, - valueNode: { type: 'Identifier', name: 'source' }, + valueNode: undefined, }, }, ], diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 92b3b3bac..a2a89d832 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -20,7 +20,7 @@ export type ModuleExportMeta = { * available. * * - The declaration for a function or class export. - * - The initializer for a variable export. + * - The initializer for a direct identifier variable export. * - The declaration expression for a default export. * - `undefined` for export specifiers and re-exports. */ @@ -147,16 +147,18 @@ export function scanModuleExports( : undefined return { node: declarator, - // uniformly handle destructured exports such as - // export const { foo, bar } = ... - // even though associated `meta` doesn't make sense anymore + // Destructured bindings remain statically unknown because the + // initializer is not the value of each individual export. exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, meta: { declName: name, isFunction, - valueNode: declarator.init ?? undefined, + valueNode: + declarator.id.type === 'Identifier' + ? (declarator.init ?? undefined) + : undefined, }, })), } diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index b4bd9a035..f0bb24539 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -1,11 +1,14 @@ import { parseAstAsync } from 'vite' import { describe, expect, test } from 'vitest' -import { transformProxyExport } from './proxy-export' +import { + transformProxyExport, + type TransformProxyExportOptions, +} from './proxy-export' import { transformWrapExport } from './wrap-export' async function testTransform( input: string, - options?: { keep?: boolean; ignoreExportAllDeclaration?: boolean }, + options?: Partial, ) { const ast = await parseAstAsync(input) const result = transformProxyExport(ast, { @@ -84,6 +87,62 @@ export const { x, y: [z] } = { x: 0, y: [1] }; `) }) + test('filter value node', async () => { + const input = `\ +export const cached = async () => {}, metadata = {}, tags = [] +export const unknown = createCached() +export const primitive = 0 +` + const result = await testTransform(input, { + filter: (_name, meta) => + meta.valueNode?.type !== 'ObjectExpression' && + meta.valueNode?.type !== 'ArrayExpression', + }) + + expect(result.exportNames).toEqual(['cached', 'unknown', 'primitive']) + expect(result.output).toMatchInlineSnapshot(` + "export const cached = /* #__PURE__ */ $$proxy("", "cached"); + + export const unknown = /* #__PURE__ */ $$proxy("", "unknown"); + + export const primitive = /* #__PURE__ */ $$proxy("", "primitive"); + + " + `) + }) + + test('filter runs before validation', async () => { + const input = `export const cached = async () => {}, metadata = {}` + const ast = await parseAstAsync(input) + const options: TransformProxyExportOptions = { + code: input, + runtime: (name) => `$$proxy(${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', + } + + expect(() => transformProxyExport(ast, options)).not.toThrow() + + const invalidInput = `${input}, primitive = 0` + const invalidAst = await parseAstAsync(invalidInput) + expect(() => + transformProxyExport(invalidAst, { ...options, code: invalidInput }), + ).toThrow('unsupported non async function') + }) + + test('filter treats destructured bindings as unknown', async () => { + const input = `export const { cached } = { cached: async () => {} }` + const ast = await parseAstAsync(input) + const result = transformProxyExport(ast, { + code: input, + runtime: (name) => `$$proxy(${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', + }) + + expect(result.exportNames).toEqual(['cached']) + }) + test('default function', async () => { const input = `export default function Fn() {}` expect(await testTransform(input)).toMatchInlineSnapshot( diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index aeebb583a..f48608f1f 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -1,7 +1,13 @@ import type { Node, Program } from 'estree' import MagicString from 'magic-string' import type { ESTree } from 'vite' -import { extractNames, hasDirective, validateNonAsyncFunction } from './utils' +import { scanModuleExports, type ModuleExportMeta } from './module-export-scan' +import { hasDirective, validateNonAsyncFunction } from './utils' + +export type TransformProxyExportFilter = ( + name: string, + meta: ModuleExportMeta, +) => boolean export type TransformProxyExportOptions = { /** Required for source map and `keep` options */ @@ -9,6 +15,7 @@ export type TransformProxyExportOptions = { runtime: (name: string, meta?: { value: string }) => string ignoreExportAllDeclaration?: boolean rejectNonAsyncFunction?: boolean + filter?: TransformProxyExportFilter /** * escape hatch for Waku's `allowServer` * @default false @@ -46,6 +53,7 @@ export function transformProxyExport( } const output = new MagicString(options.code ?? ' '.repeat(ast.end)) const exportNames: string[] = [] + const filter = options.filter ?? (() => true) function createExport(node: Node, names: string[]) { exportNames.push(...names) @@ -59,97 +67,89 @@ export function transformProxyExport( output.update(node.start, node.end, newCode) } - for (const node of ast.body) { - if (node.type === 'ExportNamedDeclaration') { - if (node.declaration) { + const exportNodes = new Set() + for (const group of scanModuleExports(viteAst)) { + const node = group.node as Node + exportNodes.add(node) + + if (group.type === 'declaration') { + const entry = group.export + if (filter(entry.exportName, entry.meta)) { + validateNonAsyncFunction(options, group.declaration) + createExport(node, [entry.exportName]) + } else { + createExport(node, []) + } + } else if (group.type === 'variable-declaration') { + const selectedNames: string[] = [] + for (const declarator of group.declarators) { + const names = declarator.exports + .filter((entry) => filter(entry.exportName, entry.meta)) + .map((entry) => entry.exportName) if ( - node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration' + names.length > 0 && + declarator.node.id.type === 'Identifier' && + declarator.node.init ) { - /** - * export function foo() {} - */ - validateNonAsyncFunction(options, node.declaration) - createExport(node, [node.declaration.id.name]) - } 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 (options.keep && options.code) { - if (node.declaration.declarations.length === 1) { - const decl = node.declaration.declarations[0]! - if (decl.id.type === 'Identifier' && decl.init) { - const name = decl.id.name - const value = options.code.slice(decl.init.start, decl.init.end) - const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( - name, - { value }, - )};` - output.update(node.start, node.end, newCode) - exportNames.push(name) - continue - } - } + validateNonAsyncFunction(options, declarator.node.init) + } + selectedNames.push(...names) + } + if (options.keep && options.code && selectedNames.length === 1) { + if (group.declaration.declarations.length === 1) { + const decl = group.declaration.declarations[0]! + if (decl.id.type === 'Identifier' && decl.init) { + const name = decl.id.name + const value = options.code.slice(decl.init.start, decl.init.end) + const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( + name, + { value }, + )};` + output.update(node.start, node.end, newCode) + exportNames.push(name) + continue } - const names = node.declaration.declarations.flatMap((decl) => - extractNames(decl.id), - ) - createExport(node, names) - } else { - node.declaration satisfies never } - } else { - /** - * export { foo, bar as car } from './foo' - * export { foo, bar as car } - */ - const names: string[] = [] - for (const spec of node.specifiers) { - if (spec.exported.type !== 'Identifier') { + } + createExport(node, selectedNames) + } else if (group.type === 'specifiers') { + const names = group.exports + .filter((entry) => { + if (entry.node.exported.type !== 'Identifier') { throw Object.assign( new Error('unsupported string literal export name'), - { pos: spec.exported.start }, + { pos: entry.node.exported.start }, ) } - names.push(spec.exported.name) - } - createExport(node, names) - } - continue - } - - /** - * export * as ns from './foo' - * export * from './foo' - */ - if (node.type === 'ExportAllDeclaration') { - if (node.exported?.type === 'Identifier') { - createExport(node, [node.exported.name]) - continue - } - if (!options.ignoreExportAllDeclaration) { + return filter(entry.exportName, entry.meta) + }) + .map((entry) => entry.exportName) + createExport(node, names) + } else if (group.type === 'export-all') { + if (group.node.exported?.type === 'Identifier') { + const name = group.node.exported.name + createExport(node, filter(name, {}) ? [name] : []) + } else if (!options.ignoreExportAllDeclaration) { throw new Error('unsupported ExportAllDeclaration') + } else if (!options.keep) { + output.remove(node.start, node.end) + } + } else if (group.type === 'default') { + if (filter('default', group.meta)) { + validateNonAsyncFunction(options, group.node.declaration) + createExport(node, ['default']) + } else { + createExport(node, []) } } + } - /** - * export default function foo() {} - * export default class Foo {} - * export default () => {} - */ - if (node.type === 'ExportDefaultDeclaration') { - validateNonAsyncFunction(options, node.declaration) - createExport(node, ['default']) - continue + if (!options.keep) { + for (const node of ast.body) { + if (!exportNodes.has(node)) { + output.remove(node.start, node.end) + } } - - if (options.keep) continue - - // remove all other nodes - output.remove(node.start, node.end) } return { exportNames, output } diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 58d01d77c..f5f6a1a34 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -392,6 +392,18 @@ export const tags = [] `) }) + test('filter treats destructured bindings as unknown', async () => { + const input = `export const { cached } = { cached: async () => {} }` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + rejectNonAsyncFunction: true, + filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', + }) + + expect(result.exportNames).toEqual(['cached']) + }) + test('filtered exports are not validated or reported', async () => { const input = ` export const revalidate = 1; diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index d7a92c690..06f3f4b23 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -189,10 +189,12 @@ export function transformWrapExport( selectedExportNames.has(exportName), ) ) { - if (declarator.node.init) { - validateNonAsyncFunction(options, declarator.node.init) - } else { - rejectNonAsyncFunction(options, declarator.node.start) + if (declarator.node.id.type === 'Identifier') { + if (declarator.node.init) { + validateNonAsyncFunction(options, declarator.node.init) + } else { + rejectNonAsyncFunction(options, declarator.node.start) + } } } } From c13edebfd368143e93f4afccea71e44d1050fb7e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:18:09 +0900 Subject: [PATCH 02/24] refactor(rsc): defer destructured export filtering Co-authored-by: OpenCode --- .../src/transforms/module-export-scan.test.ts | 2 +- .../plugin-rsc/src/transforms/module-export-scan.ts | 12 +++++------- .../plugin-rsc/src/transforms/proxy-export.test.ts | 13 ------------- packages/plugin-rsc/src/transforms/proxy-export.ts | 8 +++----- .../plugin-rsc/src/transforms/wrap-export.test.ts | 12 ------------ packages/plugin-rsc/src/transforms/wrap-export.ts | 10 ++++------ 6 files changed, 13 insertions(+), 44 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts index 05a2f19ed..054dbb193 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.test.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.test.ts @@ -72,7 +72,7 @@ export * from './all' meta: { declName: 'item', isFunction: undefined, - valueNode: undefined, + valueNode: { type: 'Identifier', name: 'source' }, }, }, ], diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index a2a89d832..92b3b3bac 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -20,7 +20,7 @@ export type ModuleExportMeta = { * available. * * - The declaration for a function or class export. - * - The initializer for a direct identifier variable export. + * - The initializer for a variable export. * - The declaration expression for a default export. * - `undefined` for export specifiers and re-exports. */ @@ -147,18 +147,16 @@ export function scanModuleExports( : undefined return { node: declarator, - // Destructured bindings remain statically unknown because the - // initializer is not the value of each individual export. + // uniformly handle destructured exports such as + // export const { foo, bar } = ... + // even though associated `meta` doesn't make sense anymore exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, meta: { declName: name, isFunction, - valueNode: - declarator.id.type === 'Identifier' - ? (declarator.init ?? undefined) - : undefined, + valueNode: declarator.init ?? undefined, }, })), } diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index f0bb24539..422e54c13 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -130,19 +130,6 @@ export const primitive = 0 ).toThrow('unsupported non async function') }) - test('filter treats destructured bindings as unknown', async () => { - const input = `export const { cached } = { cached: async () => {} }` - const ast = await parseAstAsync(input) - const result = transformProxyExport(ast, { - code: input, - runtime: (name) => `$$proxy(${JSON.stringify(name)})`, - rejectNonAsyncFunction: true, - filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', - }) - - expect(result.exportNames).toEqual(['cached']) - }) - test('default function', async () => { const input = `export default function Fn() {}` expect(await testTransform(input)).toMatchInlineSnapshot( diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index f48608f1f..3302cb414 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -83,14 +83,12 @@ export function transformProxyExport( } else if (group.type === 'variable-declaration') { const selectedNames: string[] = [] for (const declarator of group.declarators) { + // TODO: Treat destructured bindings as unknown instead of classifying + // each binding from the container initializer's `valueNode`. const names = declarator.exports .filter((entry) => filter(entry.exportName, entry.meta)) .map((entry) => entry.exportName) - if ( - names.length > 0 && - declarator.node.id.type === 'Identifier' && - declarator.node.init - ) { + if (names.length > 0 && declarator.node.init) { validateNonAsyncFunction(options, declarator.node.init) } selectedNames.push(...names) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index f5f6a1a34..58d01d77c 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -392,18 +392,6 @@ export const tags = [] `) }) - test('filter treats destructured bindings as unknown', async () => { - const input = `export const { cached } = { cached: async () => {} }` - const ast = await parseAstAsync(input) - const result = transformWrapExport(input, ast, { - runtime: (value) => `$$wrap(${value})`, - rejectNonAsyncFunction: true, - filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', - }) - - expect(result.exportNames).toEqual(['cached']) - }) - test('filtered exports are not validated or reported', async () => { const input = ` export const revalidate = 1; diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 06f3f4b23..d7a92c690 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -189,12 +189,10 @@ export function transformWrapExport( selectedExportNames.has(exportName), ) ) { - if (declarator.node.id.type === 'Identifier') { - if (declarator.node.init) { - validateNonAsyncFunction(options, declarator.node.init) - } else { - rejectNonAsyncFunction(options, declarator.node.start) - } + if (declarator.node.init) { + validateNonAsyncFunction(options, declarator.node.init) + } else { + rejectNonAsyncFunction(options, declarator.node.start) } } } From fd256cfc99c9c350b0325bc629ec90fad3953803 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:18:52 +0900 Subject: [PATCH 03/24] docs(rsc): locate destructuring filter todo Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-export-scan.ts | 2 ++ packages/plugin-rsc/src/transforms/proxy-export.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 92b3b3bac..90f96eb8c 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -150,6 +150,8 @@ export function scanModuleExports( // uniformly handle destructured exports such as // export const { foo, bar } = ... // even though associated `meta` doesn't make sense anymore + // TODO: Treat destructured bindings as unknown instead of + // using the container initializer as each `valueNode`. exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 3302cb414..9f0e503b9 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -83,8 +83,6 @@ export function transformProxyExport( } else if (group.type === 'variable-declaration') { const selectedNames: string[] = [] for (const declarator of group.declarators) { - // TODO: Treat destructured bindings as unknown instead of classifying - // each binding from the container initializer's `valueNode`. const names = declarator.exports .filter((entry) => filter(entry.exportName, entry.meta)) .map((entry) => entry.exportName) From 89c50d2bfc2afc0a298c1ce9dfbda0b7dc1af50f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:21:32 +0900 Subject: [PATCH 04/24] fix(rsc): reject proxy filter with keep Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/proxy-export.test.ts | 9 +++++++++ packages/plugin-rsc/src/transforms/proxy-export.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 422e54c13..8dc84ab64 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -343,4 +343,13 @@ export const MyClientComp = () => { throw new Error('...') } } `) }) + + test('filter with keep throws', async () => { + await expect( + testTransform(`export const action = () => {}`, { + keep: true, + filter: () => true, + }), + ).rejects.toThrow('`filter` option is not supported with `keep`') + }) }) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 9f0e503b9..fb0f56e90 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -51,6 +51,9 @@ export function transformProxyExport( if (options.keep && typeof options.code !== 'string') { throw new Error('`keep` option requires `code`') } + if (options.keep && options.filter) { + throw new Error('`filter` option is not supported with `keep`') + } const output = new MagicString(options.code ?? ' '.repeat(ast.end)) const exportNames: string[] = [] const filter = options.filter ?? (() => true) From a096d9092fb4ae2368c07735e81a26d562258a2d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:24:45 +0900 Subject: [PATCH 05/24] fix(rsc): preserve empty binding validation Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.test.ts | 11 +++++++++++ packages/plugin-rsc/src/transforms/proxy-export.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 8dc84ab64..97ccc8f49 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -130,6 +130,17 @@ export const primitive = 0 ).toThrow('unsupported non async function') }) + test.each(['{}', '[]'])( + 'validates empty binding %s without filter', + async (id) => { + await expect( + testTransform(`export const ${id} = ${id}`, { + rejectNonAsyncFunction: true, + }), + ).rejects.toThrow('unsupported non async function') + }, + ) + test('default function', async () => { const input = `export default function Fn() {}` expect(await testTransform(input)).toMatchInlineSnapshot( diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index fb0f56e90..b6dbe7005 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -89,7 +89,7 @@ export function transformProxyExport( const names = declarator.exports .filter((entry) => filter(entry.exportName, entry.meta)) .map((entry) => entry.exportName) - if (names.length > 0 && declarator.node.init) { + if (declarator.node.init && (!options.filter || names.length > 0)) { validateNonAsyncFunction(options, declarator.node.init) } selectedNames.push(...names) From bd588637bb4fc51c2158e055a49d051cd9352c11 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:26:33 +0900 Subject: [PATCH 06/24] fix(rsc): validate filtered empty bindings Co-authored-by: OpenCode --- .../src/transforms/proxy-export.test.ts | 23 +++++++++++-------- .../plugin-rsc/src/transforms/proxy-export.ts | 7 +++++- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 97ccc8f49..ce93d6a82 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -130,16 +130,19 @@ export const primitive = 0 ).toThrow('unsupported non async function') }) - test.each(['{}', '[]'])( - 'validates empty binding %s without filter', - async (id) => { - await expect( - testTransform(`export const ${id} = ${id}`, { - rejectNonAsyncFunction: true, - }), - ).rejects.toThrow('unsupported non async function') - }, - ) + test.each([ + ['{}', undefined], + ['[]', undefined], + ['{}', () => true], + ['[]', () => true], + ])('validates empty binding %s with filter %s', async (id, filter) => { + await expect( + testTransform(`export const ${id} = ${id}`, { + rejectNonAsyncFunction: true, + filter, + }), + ).rejects.toThrow('unsupported non async function') + }) test('default function', async () => { const input = `export default function Fn() {}` diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index b6dbe7005..32794f80b 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -89,7 +89,12 @@ export function transformProxyExport( const names = declarator.exports .filter((entry) => filter(entry.exportName, entry.meta)) .map((entry) => entry.exportName) - if (declarator.node.init && (!options.filter || names.length > 0)) { + if ( + declarator.node.init && + (!options.filter || + declarator.exports.length === 0 || + names.length > 0) + ) { validateNonAsyncFunction(options, declarator.node.init) } selectedNames.push(...names) From 5453a3115169a40b184eb169c7169d87d4e08ae0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:45:40 +0900 Subject: [PATCH 07/24] docs(rsc): explain ordinary cache exports Co-authored-by: OpenCode --- .../src/features/file-directive-from-client/action.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index ea376cff8..77a1cc304 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -2,6 +2,8 @@ import { state } from './state' +// Ordinary values remain available from "use cache" modules without becoming +// callable server references. export const metadata = { title: 'cached metadata' } export const tags = ['cache'] From 67b592aea9f43b4b017a25fa2c75bc3e9e3d0901 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:59:51 +0900 Subject: [PATCH 08/24] test(rsc): clarify object and array exports Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/use-cache-callable.test.ts | 2 +- .../src/features/file-directive-from-client/action.ts | 4 ++-- .../src/features/file-directive-from-server/action.ts | 4 ++-- .../src/features/file-directive-from-server/server.tsx | 4 ++-- packages/plugin-rsc/src/transforms/proxy-export.test.ts | 4 ++-- packages/plugin-rsc/src/transforms/wrap-export.test.ts | 8 ++++---- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/plugin-rsc/e2e/use-cache-callable.test.ts b/packages/plugin-rsc/e2e/use-cache-callable.test.ts index 5a2ae9be7..2344927e0 100644 --- a/packages/plugin-rsc/e2e/use-cache-callable.test.ts +++ b/packages/plugin-rsc/e2e/use-cache-callable.test.ts @@ -103,7 +103,7 @@ function defineTests(f: Fixture) { await page.getByRole('button', { name: 'Reset' }).click() await expect(submissionCount).toHaveText('0') await expect(executionCount).toHaveText('0') - await expect(ordinaryExports).toHaveText('cached metadata: cache') + await expect(ordinaryExports).toHaveText('object: array') await expect(result).toHaveText('not called') // The wrapped export is passed from a Server Component to a Client Component. diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index 77a1cc304..ef5b90038 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -4,8 +4,8 @@ import { state } from './state' // Ordinary values remain available from "use cache" modules without becoming // callable server references. -export const metadata = { title: 'cached metadata' } -export const tags = ['cache'] +export const objectValue = { text: 'object' } +export const arrayValue = ['array'] export async function cachedFromClient(formData: FormData) { const argument = String(formData.get('argument')) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts index 2de9df9ca..f21769c70 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -4,8 +4,8 @@ import { state } from './state' // Ordinary values remain available from "use cache" modules without becoming // callable server references. -export const metadata = { title: 'cached metadata' } -export const tags = ['cache'] +export const objectValue = { text: 'object' } +export const arrayValue = ['array'] export async function cachedFromServer(formData: FormData) { const argument = String(formData.get('argument')) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx index 3f09befef..394509bef 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/server.tsx @@ -1,4 +1,4 @@ -import { cachedFromServer, metadata, tags } from './action' +import { arrayValue, cachedFromServer, objectValue } from './action' import { FileDirectiveFromServerClient } from './client' import { resetAction } from './reset' import { state } from './state' @@ -8,7 +8,7 @@ export function FileDirectiveFromServer() { diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index ce93d6a82..401d4ac7e 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -89,7 +89,7 @@ export const { x, y: [z] } = { x: 0, y: [1] }; test('filter value node', async () => { const input = `\ -export const cached = async () => {}, metadata = {}, tags = [] +export const cached = async () => {}, objectValue = {}, arrayValue = [] export const unknown = createCached() export const primitive = 0 ` @@ -112,7 +112,7 @@ export const primitive = 0 }) test('filter runs before validation', async () => { - const input = `export const cached = async () => {}, metadata = {}` + const input = `export const cached = async () => {}, objectValue = {}` const ast = await parseAstAsync(input) const options: TransformProxyExportOptions = { code: input, diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 58d01d77c..0d745600c 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -369,8 +369,8 @@ export default Page; test('filter value node', async () => { const input = `\ export const action = async () => {} -export const metadata = {} -export const tags = [] +export const objectValue = {} +export const arrayValue = [] ` const ast = await parseAstAsync(input) const result = transformWrapExport(input, ast, { @@ -384,8 +384,8 @@ export const tags = [] expect(result.exportNames).toEqual(['action']) expect(result.output.toString()).toMatchInlineSnapshot(` "let action = async () => {} - export const metadata = {} - export const tags = [] + export const objectValue = {} + export const arrayValue = [] action = /* #__PURE__ */ $$wrap(action, "action"); export { action }; " From 19c4ce1c0c63a2981af819d8462ec70d3d10b1b8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:01:26 +0900 Subject: [PATCH 09/24] docs(rsc): clarify cache export exception Co-authored-by: OpenCode --- .../src/features/file-directive-from-client/action.ts | 4 ++-- .../src/features/file-directive-from-server/action.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts index ef5b90038..04aaa4b72 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-client/action.ts @@ -2,8 +2,8 @@ import { state } from './state' -// Ordinary values remain available from "use cache" modules without becoming -// callable server references. +// Next.js excludes statically known object and array exports from "use cache" +// server-reference handling. The transform filter mirrors that narrow case. export const objectValue = { text: 'object' } export const arrayValue = ['array'] diff --git a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts index f21769c70..0573bd770 100644 --- a/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts +++ b/packages/plugin-rsc/examples/use-cache-callable/src/features/file-directive-from-server/action.ts @@ -2,8 +2,8 @@ import { state } from './state' -// Ordinary values remain available from "use cache" modules without becoming -// callable server references. +// Next.js excludes statically known object and array exports from "use cache" +// server-reference handling. The transform filter mirrors that narrow case. export const objectValue = { text: 'object' } export const arrayValue = ['array'] From 20ed023c60abeed877706f2cfdcddacdc5e37fba Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:03:13 +0900 Subject: [PATCH 10/24] test(rsc): document destructured proxy follow-up Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/module-export-scan.ts | 1 + .../plugin-rsc/src/transforms/proxy-export.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 90f96eb8c..9a18cdea4 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -152,6 +152,7 @@ export function scanModuleExports( // even though associated `meta` doesn't make sense anymore // TODO: Treat destructured bindings as unknown instead of // using the container initializer as each `valueNode`. + // See the pending destructured-binding proxy export test. exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 401d4ac7e..63a0ba021 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -130,6 +130,16 @@ export const primitive = 0 ).toThrow('unsupported non async function') }) + test.todo('filter treats destructured bindings as unknown', async () => { + const input = `export const { cached } = { cached: async () => {} }` + const result = await testTransform(input, { + rejectNonAsyncFunction: true, + filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', + }) + + expect(result.exportNames).toEqual(['cached']) + }) + test.each([ ['{}', undefined], ['[]', undefined], From 6c7f1a1e7f917549a1f7b253f6b7dd1f46109468 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:04:55 +0900 Subject: [PATCH 11/24] test(rsc): characterize destructured proxy limitation Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-export-scan.ts | 2 +- packages/plugin-rsc/src/transforms/proxy-export.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 9a18cdea4..2f7c6d007 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -152,7 +152,7 @@ export function scanModuleExports( // even though associated `meta` doesn't make sense anymore // TODO: Treat destructured bindings as unknown instead of // using the container initializer as each `valueNode`. - // See the pending destructured-binding proxy export test. + // See the destructured-binding proxy export regression test. exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 63a0ba021..135a474df 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -130,14 +130,18 @@ export const primitive = 0 ).toThrow('unsupported non async function') }) - test.todo('filter treats destructured bindings as unknown', async () => { + test('filter classifies destructured bindings from their container', async () => { const input = `export const { cached } = { cached: async () => {} }` const result = await testTransform(input, { rejectNonAsyncFunction: true, filter: (_name, meta) => meta.valueNode?.type !== 'ObjectExpression', }) - expect(result.exportNames).toEqual(['cached']) + // TODO: A destructured binding should have no `valueNode` because the + // container is not its value. The filter should therefore conservatively + // select `cached` without validating the object initializer, resulting in + // `exportNames: ['cached']`. + expect(result.exportNames).toEqual([]) }) test.each([ From 7ed81c41b44c911de9550fca70b49102e4e97548 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:06:17 +0900 Subject: [PATCH 12/24] docs(rsc): link destructured export reference Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-export-scan.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index 2f7c6d007..e369b66a7 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -153,6 +153,7 @@ export function scanModuleExports( // TODO: Treat destructured bindings as unknown instead of // using the container initializer as each `valueNode`. // See the destructured-binding proxy export regression test. + // https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/src/transforms/server_actions.rs#L1787-L1815 exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, From 5c54ec93cdd7be8e11f1579fb394eca65e1abf05 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:07:15 +0900 Subject: [PATCH 13/24] docs(rsc): link destructured proxy fixture Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/proxy-export.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 135a474df..77883e63e 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -141,6 +141,7 @@ export const primitive = 0 // container is not its value. The filter should therefore conservatively // select `cached` without validating the object initializer, resulting in // `exportNames: ['cached']`. + // https://github.com/vercel/next.js/tree/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/tests/fixture/server-actions/client-graph/14 expect(result.exportNames).toEqual([]) }) From 14899ac610564ee0197c8cfeb91f67ccb68defaa Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:08:39 +0900 Subject: [PATCH 14/24] docs(rsc): clarify destructured export scope Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-export-scan.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index e369b66a7..fa2084fb3 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -150,8 +150,9 @@ export function scanModuleExports( // uniformly handle destructured exports such as // export const { foo, bar } = ... // even though associated `meta` doesn't make sense anymore - // TODO: Treat destructured bindings as unknown instead of - // using the container initializer as each `valueNode`. + // TODO: Treat destructured bindings as unknown for both + // "use server" and "use cache" instead of using the container + // initializer as each binding's `valueNode`. // See the destructured-binding proxy export regression test. // https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/crates/next-custom-transforms/src/transforms/server_actions.rs#L1787-L1815 exports: extractNames(declarator.id).map((name) => ({ From ee3cb3c3b6d7d77899f55d37d313e66b1061fa31 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:23:57 +0900 Subject: [PATCH 15/24] docs(rsc): illustrate proxy export transform Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 32794f80b..652efb6f7 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -40,6 +40,34 @@ export function transformDirectiveProxyExport( return transformProxyExport(ast, options) } +/** + * Replaces selected exports with proxies created by `runtime` and removes the + * original module implementation. + * + * Conceptually, with a filter that excludes `objectValue`: + * + * ```js + * import { dependency } from './dep' + * export async function action() {} + * export const objectValue = {} + * export { dependency as renamed } + * export default async () => {} + * ``` + * + * becomes: + * + * ```js + * export const action = __PROXY__('action') + * export const renamed = __PROXY__('renamed') + * export default __PROXY__('default') + * ``` + * + * Unlike `transformWrapExport`, this transform does not evaluate the original + * exports. Unknown values are represented only by their export names, so the + * caller must filter solely from the available static `ModuleExportMeta`. + * `keep` is a specialized mode that retains non-export implementation code and + * passes a single variable initializer to `runtime`. + */ export function transformProxyExport( viteAst: ESTree.Program, options: TransformProxyExportOptions, @@ -58,6 +86,7 @@ export function transformProxyExport( const exportNames: string[] = [] const filter = options.filter ?? (() => true) + /** Replaces one complete export statement with zero or more proxy exports. */ function createExport(node: Node, names: string[]) { exportNames.push(...names) const newCode = names @@ -76,6 +105,8 @@ export function transformProxyExport( exportNodes.add(node) if (group.type === 'declaration') { + // export function action() {} + // -> export const action = __PROXY__('action') const entry = group.export if (filter(entry.exportName, entry.meta)) { validateNonAsyncFunction(options, group.declaration) @@ -84,6 +115,8 @@ export function transformProxyExport( createExport(node, []) } } else if (group.type === 'variable-declaration') { + // export const selected = init(), skipped = {} + // -> export const selected = __PROXY__('selected') const selectedNames: string[] = [] for (const declarator of group.declarators) { const names = declarator.exports @@ -100,6 +133,9 @@ export function transformProxyExport( selectedNames.push(...names) } if (options.keep && options.code && selectedNames.length === 1) { + // Waku's `keep` mode retains the initializer as the proxy value: + // export const value = init() + // -> export const value = __PROXY__(init(), 'value') if (group.declaration.declarations.length === 1) { const decl = group.declaration.declarations[0]! if (decl.id.type === 'Identifier' && decl.init) { @@ -117,6 +153,8 @@ export function transformProxyExport( } createExport(node, selectedNames) } else if (group.type === 'specifiers') { + // export { local as renamed } from './dep' + // -> export const renamed = __PROXY__('renamed') const names = group.exports .filter((entry) => { if (entry.node.exported.type !== 'Identifier') { @@ -130,6 +168,8 @@ export function transformProxyExport( .map((entry) => entry.exportName) createExport(node, names) } else if (group.type === 'export-all') { + // A namespace re-export has one known name. A bare export-all cannot be + // represented without resolving the dependency's export names. if (group.node.exported?.type === 'Identifier') { const name = group.node.exported.name createExport(node, filter(name, {}) ? [name] : []) @@ -139,6 +179,8 @@ export function transformProxyExport( output.remove(node.start, node.end) } } else if (group.type === 'default') { + // export default async () => {} + // -> export default __PROXY__('default') if (filter('default', group.meta)) { validateNonAsyncFunction(options, group.node.declaration) createExport(node, ['default']) @@ -149,6 +191,8 @@ export function transformProxyExport( } if (!options.keep) { + // Imports, directives, and implementation statements must not execute in + // the graph that consumes these proxies. for (const node of ast.body) { if (!exportNodes.has(node)) { output.remove(node.start, node.end) From 8f9466b254a449bfd9f51bf2842ebf47622a3130 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:34:03 +0900 Subject: [PATCH 16/24] docs(rsc): document proxy export options Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 652efb6f7..898a7283c 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -4,20 +4,45 @@ import type { ESTree } from 'vite' import { scanModuleExports, type ModuleExportMeta } from './module-export-scan' import { hasDirective, validateNonAsyncFunction } from './utils' +/** Selects which statically discovered exports become proxies. */ export type TransformProxyExportFilter = ( name: string, meta: ModuleExportMeta, ) => boolean export type TransformProxyExportOptions = { - /** Required for source map and `keep` options */ + /** + * Original module source used to preserve source mappings and initializer + * text. Required by `keep`. + */ code?: string + /** + * Returns the proxy expression for an export name. In `keep` mode, `value` + * contains the original initializer for a single identifier declaration. + */ runtime: (name: string, meta?: { value: string }) => string + /** + * Removes a bare `export *` instead of rejecting it. With `keep`, the + * declaration is retained. + * @default false + */ ignoreExportAllDeclaration?: boolean + /** Rejects statically known values that are not async functions. */ rejectNonAsyncFunction?: boolean + /** + * Selects exports before validation and proxy generation. Filtered exports + * are omitted from both the generated module and `exportNames`. + * + * Cannot be combined with `keep`. + * @default () => true + */ filter?: TransformProxyExportFilter /** - * escape hatch for Waku's `allowServer` + * Retains non-export implementation code and passes a single identifier + * declaration's initializer to `runtime`. This is an escape hatch for Waku's + * `allowServer` transform. + * + * Requires `code` and cannot be combined with `filter`. * @default false */ keep?: boolean From 296d989d6e05df5c29735176bfd8ae8bf690abf9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:34:43 +0900 Subject: [PATCH 17/24] refactor(rsc): name proxy export result Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 898a7283c..e2bbb91be 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -48,17 +48,17 @@ export type TransformProxyExportOptions = { keep?: boolean } +export type TransformProxyExportResult = { + exportNames: string[] + output: MagicString +} + export function transformDirectiveProxyExport( ast: ESTree.Program, options: { directive: string } & TransformProxyExportOptions, -): - | { - exportNames: string[] - output: MagicString - } - | undefined { +): TransformProxyExportResult | undefined { if (!hasDirective(ast.body, options.directive)) { return } @@ -96,10 +96,7 @@ export function transformDirectiveProxyExport( export function transformProxyExport( viteAst: ESTree.Program, options: TransformProxyExportOptions, -): { - exportNames: string[] - output: MagicString -} { +): TransformProxyExportResult { const ast = viteAst as unknown as Program if (options.keep && typeof options.code !== 'string') { throw new Error('`keep` option requires `code`') From 752abefa69e9dde2f2af86fdc88e4ca3630a3197 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:22:49 +0900 Subject: [PATCH 18/24] refactor(rsc): group proxy fixture options Co-authored-by: OpenCode --- .../src/transforms/source-map.test.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/source-map.test.ts b/packages/plugin-rsc/src/transforms/source-map.test.ts index a18b4c152..32aa8a028 100644 --- a/packages/plugin-rsc/src/transforms/source-map.test.ts +++ b/packages/plugin-rsc/src/transforms/source-map.test.ts @@ -6,7 +6,7 @@ import { transformHoistInlineDirective } from './hoist' import { transformModuleExportEffect } from './module-export-effect' import { transformProxyExport, - type TransformProxyExportFilter, + type TransformProxyExportOptions, } from './proxy-export' import { formatDecodedSourceMapMarkdown, @@ -92,24 +92,27 @@ describe('source map fixtures', () => { string, { name: string - keep?: boolean - ignoreExportAllDeclaration?: boolean - filter?: TransformProxyExportFilter + options?: Partial }[] > = { './fixtures/source-map/proxy-export/export-all-ignore.js': [ - { name: 'proxy-export', ignoreExportAllDeclaration: true }, + { + name: 'proxy-export', + options: { ignoreExportAllDeclaration: true }, + }, ], './fixtures/source-map/proxy-export/keep.js': [ { name: 'proxy-export' }, - { name: 'proxy-export-keep', keep: true }, + { name: 'proxy-export-keep', options: { keep: true } }, ], './fixtures/source-map/proxy-export/filter-value-node.js': [ { name: 'proxy-export-filtered', - filter: (_name, meta) => - meta.valueNode?.type !== 'ObjectExpression' && - meta.valueNode?.type !== 'ArrayExpression', + options: { + filter: (_name, meta) => + meta.valueNode?.type !== 'ObjectExpression' && + meta.valueNode?.type !== 'ArrayExpression', + }, }, ], } @@ -120,7 +123,7 @@ describe('source map fixtures', () => { const variants = proxyExportFixtureVariants[file] ?? [ { name: 'proxy-export' }, ] - const outputs = variants.map(({ name, ...options }) => { + const outputs = variants.map(({ name, options }) => { const result = transformProxyExport(ast, { code: input, ...options, From 091019a714da22ad0b9a333dfa82a5818143d9d0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:39:01 +0900 Subject: [PATCH 19/24] refactor(rsc): remove filtered proxy exports explicitly Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index e2bbb91be..345e8b393 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -108,7 +108,7 @@ export function transformProxyExport( const exportNames: string[] = [] const filter = options.filter ?? (() => true) - /** Replaces one complete export statement with zero or more proxy exports. */ + /** Replaces one complete export statement with proxy exports. */ function createExport(node: Node, names: string[]) { exportNames.push(...names) const newCode = names @@ -134,7 +134,7 @@ export function transformProxyExport( validateNonAsyncFunction(options, group.declaration) createExport(node, [entry.exportName]) } else { - createExport(node, []) + output.remove(node.start, node.end) } } else if (group.type === 'variable-declaration') { // export const selected = init(), skipped = {} @@ -173,7 +173,11 @@ export function transformProxyExport( } } } - createExport(node, selectedNames) + if (selectedNames.length > 0) { + createExport(node, selectedNames) + } else { + output.remove(node.start, node.end) + } } else if (group.type === 'specifiers') { // export { local as renamed } from './dep' // -> export const renamed = __PROXY__('renamed') @@ -188,13 +192,21 @@ export function transformProxyExport( return filter(entry.exportName, entry.meta) }) .map((entry) => entry.exportName) - createExport(node, names) + if (names.length > 0) { + createExport(node, names) + } else { + output.remove(node.start, node.end) + } } else if (group.type === 'export-all') { // A namespace re-export has one known name. A bare export-all cannot be // represented without resolving the dependency's export names. if (group.node.exported?.type === 'Identifier') { const name = group.node.exported.name - createExport(node, filter(name, {}) ? [name] : []) + if (filter(name, {})) { + createExport(node, [name]) + } else { + output.remove(node.start, node.end) + } } else if (!options.ignoreExportAllDeclaration) { throw new Error('unsupported ExportAllDeclaration') } else if (!options.keep) { @@ -207,7 +219,7 @@ export function transformProxyExport( validateNonAsyncFunction(options, group.node.declaration) createExport(node, ['default']) } else { - createExport(node, []) + output.remove(node.start, node.end) } } } From 59f282516e515587c1f051c35c4d80cb6022d60e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:55:22 +0900 Subject: [PATCH 20/24] docs(rsc): clarify namespace proxy exports Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/proxy-export.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 345e8b393..63ae07de0 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -200,7 +200,11 @@ export function transformProxyExport( } else if (group.type === 'export-all') { // A namespace re-export has one known name. A bare export-all cannot be // represented without resolving the dependency's export names. + // TODO: Reject `export * as "name"` as an unsupported string literal + // export name instead of handling it like a bare `export *`. if (group.node.exported?.type === 'Identifier') { + // export * as dep from './dep' + // -> export const dep = __PROXY__('dep') const name = group.node.exported.name if (filter(name, {})) { createExport(node, [name]) From c18f8b2745e9645b2f94935b8c06899ec10086f4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:56:46 +0900 Subject: [PATCH 21/24] refactor(rsc): extract proxy node removal Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 63ae07de0..1c24451c1 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -121,6 +121,10 @@ export function transformProxyExport( output.update(node.start, node.end, newCode) } + function removeNode(node: Node) { + output.remove(node.start, node.end) + } + const exportNodes = new Set() for (const group of scanModuleExports(viteAst)) { const node = group.node as Node @@ -134,7 +138,7 @@ export function transformProxyExport( validateNonAsyncFunction(options, group.declaration) createExport(node, [entry.exportName]) } else { - output.remove(node.start, node.end) + removeNode(node) } } else if (group.type === 'variable-declaration') { // export const selected = init(), skipped = {} @@ -176,7 +180,7 @@ export function transformProxyExport( if (selectedNames.length > 0) { createExport(node, selectedNames) } else { - output.remove(node.start, node.end) + removeNode(node) } } else if (group.type === 'specifiers') { // export { local as renamed } from './dep' @@ -195,7 +199,7 @@ export function transformProxyExport( if (names.length > 0) { createExport(node, names) } else { - output.remove(node.start, node.end) + removeNode(node) } } else if (group.type === 'export-all') { // A namespace re-export has one known name. A bare export-all cannot be @@ -209,12 +213,12 @@ export function transformProxyExport( if (filter(name, {})) { createExport(node, [name]) } else { - output.remove(node.start, node.end) + removeNode(node) } } else if (!options.ignoreExportAllDeclaration) { throw new Error('unsupported ExportAllDeclaration') } else if (!options.keep) { - output.remove(node.start, node.end) + removeNode(node) } } else if (group.type === 'default') { // export default async () => {} @@ -223,7 +227,7 @@ export function transformProxyExport( validateNonAsyncFunction(options, group.node.declaration) createExport(node, ['default']) } else { - output.remove(node.start, node.end) + removeNode(node) } } } @@ -233,7 +237,7 @@ export function transformProxyExport( // the graph that consumes these proxies. for (const node of ast.body) { if (!exportNodes.has(node)) { - output.remove(node.start, node.end) + removeNode(node) } } } From 3ace8e868ae261f36f78a3c60a22437314dd6d4d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:07:11 +0900 Subject: [PATCH 22/24] refactor(rsc): handle empty proxy selections Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 1c24451c1..8554a434d 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -108,8 +108,16 @@ export function transformProxyExport( const exportNames: string[] = [] const filter = options.filter ?? (() => true) - /** Replaces one complete export statement with proxy exports. */ + function removeNode(node: Node) { + output.remove(node.start, node.end) + } + + /** Replaces one complete export statement with the selected proxy exports. */ function createExport(node: Node, names: string[]) { + if (names.length === 0) { + removeNode(node) + return + } exportNames.push(...names) const newCode = names .map( @@ -121,10 +129,6 @@ export function transformProxyExport( output.update(node.start, node.end, newCode) } - function removeNode(node: Node) { - output.remove(node.start, node.end) - } - const exportNodes = new Set() for (const group of scanModuleExports(viteAst)) { const node = group.node as Node @@ -177,11 +181,7 @@ export function transformProxyExport( } } } - if (selectedNames.length > 0) { - createExport(node, selectedNames) - } else { - removeNode(node) - } + createExport(node, selectedNames) } else if (group.type === 'specifiers') { // export { local as renamed } from './dep' // -> export const renamed = __PROXY__('renamed') @@ -196,11 +196,7 @@ export function transformProxyExport( return filter(entry.exportName, entry.meta) }) .map((entry) => entry.exportName) - if (names.length > 0) { - createExport(node, names) - } else { - removeNode(node) - } + createExport(node, names) } else if (group.type === 'export-all') { // A namespace re-export has one known name. A bare export-all cannot be // represented without resolving the dependency's export names. From eee7f87ec5a71d1196dc38fc8d287cc531a315f4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:09:31 +0900 Subject: [PATCH 23/24] refactor(rsc): simplify empty proxy selection Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/proxy-export.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 8554a434d..77244ac92 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -114,10 +114,6 @@ export function transformProxyExport( /** Replaces one complete export statement with the selected proxy exports. */ function createExport(node: Node, names: string[]) { - if (names.length === 0) { - removeNode(node) - return - } exportNames.push(...names) const newCode = names .map( From 00381b00f5fdaab3696819960301c2d4ef356dd6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:12:35 +0900 Subject: [PATCH 24/24] refactor(rsc): simplify keep proxy handling Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/proxy-export.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 77244ac92..b7daf4834 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -158,23 +158,21 @@ export function transformProxyExport( } selectedNames.push(...names) } - if (options.keep && options.code && selectedNames.length === 1) { + if (options.keep && group.declaration.declarations.length === 1) { // Waku's `keep` mode retains the initializer as the proxy value: // export const value = init() // -> export const value = __PROXY__(init(), 'value') - if (group.declaration.declarations.length === 1) { - const decl = group.declaration.declarations[0]! - if (decl.id.type === 'Identifier' && decl.init) { - const name = decl.id.name - const value = options.code.slice(decl.init.start, decl.init.end) - const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( - name, - { value }, - )};` - output.update(node.start, node.end, newCode) - exportNames.push(name) - continue - } + const decl = group.declaration.declarations[0]! + if (decl.id.type === 'Identifier' && decl.init) { + const name = decl.id.name + const value = options.code!.slice(decl.init.start, decl.init.end) + const newCode = `export const ${name} = /* #__PURE__ */ ${options.runtime( + name, + { value }, + )};` + output.update(node.start, node.end, newCode) + exportNames.push(name) + continue } } createExport(node, selectedNames)