From b2fa42ae10f2a2568ac4286781b5b5b42cadae09 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:42:19 +0900 Subject: [PATCH 01/14] refactor(rsc): extract module export scan Co-authored-by: OpenCode --- .../src/transforms/module-exports.test.ts | 117 ++++++++ .../src/transforms/module-exports.ts | 201 +++++++++++++ .../src/transforms/wrap-export.test.ts | 33 +++ .../plugin-rsc/src/transforms/wrap-export.ts | 276 ++++++++---------- 4 files changed, 466 insertions(+), 161 deletions(-) create mode 100644 packages/plugin-rsc/src/transforms/module-exports.test.ts create mode 100644 packages/plugin-rsc/src/transforms/module-exports.ts diff --git a/packages/plugin-rsc/src/transforms/module-exports.test.ts b/packages/plugin-rsc/src/transforms/module-exports.test.ts new file mode 100644 index 000000000..aeaeb025c --- /dev/null +++ b/packages/plugin-rsc/src/transforms/module-exports.test.ts @@ -0,0 +1,117 @@ +import { parseAstAsync } from 'vite' +import { expect, test } from 'vitest' +import { scanModuleExports } from './module-exports' + +test(scanModuleExports, async () => { + const ast = await parseAstAsync(` +export async function action() {} +export const loader = async () => {}, value = 1 +export const { item } = source +export { loader as renamed } +export { remote as reexported } from './dep' +export default action +export * from './all' +`) + + const groups = scanModuleExports(ast) + + expect(groups).toHaveLength(7) + expect(groups[0]).toMatchObject({ + type: 'declaration', + declaration: { type: 'FunctionDeclaration' }, + exports: [ + { + localName: 'action', + exportName: 'action', + meta: { + localName: 'action', + declarationKind: 'function', + isFunction: true, + }, + }, + ], + }) + expect(groups[1]).toMatchObject({ + type: 'variable-declaration', + declaration: { kind: 'const' }, + declarators: [ + { + exports: [ + { + localName: 'loader', + exportName: 'loader', + meta: { + localName: 'loader', + declarationKind: 'const', + isFunction: true, + }, + }, + ], + }, + { + exports: [ + { + localName: 'value', + exportName: 'value', + meta: { + localName: 'value', + declarationKind: 'const', + isFunction: false, + }, + }, + ], + }, + ], + }) + expect(groups[2]).toMatchObject({ + type: 'variable-declaration', + declarators: [ + { + exports: [ + { + localName: 'item', + exportName: 'item', + meta: { + localName: 'item', + declarationKind: 'const', + isFunction: undefined, + }, + }, + ], + }, + ], + }) + expect(groups[3]).toMatchObject({ + type: 'specifiers', + node: { source: null }, + exports: [{ localName: 'loader', exportName: 'renamed', meta: {} }], + }) + expect(groups[4]).toMatchObject({ + type: 'specifiers', + node: { source: { value: './dep' } }, + exports: [{ localName: 'remote', exportName: 'reexported', meta: {} }], + }) + expect(groups[5]).toMatchObject({ + type: 'default', + localName: undefined, + meta: { defaultExportIdentifierName: 'action' }, + }) + expect(groups[6]).toMatchObject({ type: 'export-all' }) +}) + +test('preserves string literal export names', async () => { + const ast = await parseAstAsync(`export { local as "public name" }`) + + expect(scanModuleExports(ast)).toMatchObject([ + { + type: 'specifiers', + exports: [ + { + localName: 'local', + exportName: 'public name', + node: { exported: { type: 'Literal' } }, + }, + ], + }, + ]) +}) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts new file mode 100644 index 000000000..6c9c97d88 --- /dev/null +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -0,0 +1,201 @@ +import { tinyassert } from '@hiogawa/utils' +import type { + ExportDefaultDeclaration, + ExportNamedDeclaration, + ExportSpecifier, + FunctionDeclaration, + ClassDeclaration, + Node, + Program, + VariableDeclaration, + VariableDeclarator, +} from 'estree' +import type { ESTree } from 'vite' +import { extractNames } from './utils' + +export type ModuleExportMeta = { + localName?: string + declarationKind?: 'function' | 'class' | VariableDeclaration['kind'] + /** Whether the exported value is statically known to be a function. */ + isFunction?: boolean + /** Local identifier referenced by `export default Identifier`. */ + defaultExportIdentifierName?: string +} + +export type ModuleExportEntry = { + localName: string + exportName: string + meta: ModuleExportMeta +} + +export type ModuleExportSpecifier = { + node: ExportSpecifier + localName: string + exportName: string + meta: ModuleExportMeta +} + +export type ModuleExportGroup = + | { + type: 'declaration' + node: ExportNamedDeclaration + declaration: FunctionDeclaration | ClassDeclaration + exports: [ModuleExportEntry] + } + | { + type: 'variable-declaration' + node: ExportNamedDeclaration + declaration: Extract< + ExportNamedDeclaration['declaration'], + { type: 'VariableDeclaration' } + > + declarators: { + node: VariableDeclarator + exports: ModuleExportEntry[] + }[] + } + | { + type: 'specifiers' + node: ExportNamedDeclaration + exports: ModuleExportSpecifier[] + } + | { + type: 'export-all' + node: Extract + } + | { + type: 'default' + node: ExportDefaultDeclaration + localName?: string + meta: ModuleExportMeta + } + +export function scanModuleExports( + viteAst: ESTree.Program, +): ModuleExportGroup[] { + const ast = viteAst as unknown as Program + const groups: ModuleExportGroup[] = [] + + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + if (node.declaration) { + if (node.declaration.type === 'VariableDeclaration') { + const declaration = node.declaration + groups.push({ + type: 'variable-declaration', + node, + declaration, + declarators: declaration.declarations.map((declarator) => { + const isFunction = + declarator.id.type === 'Identifier' && declarator.init + ? getIsFunction(declarator.init) + : undefined + return { + node: declarator, + exports: extractNames(declarator.id).map((name) => ({ + localName: name, + exportName: name, + meta: { + localName: name, + declarationKind: declaration.kind, + isFunction, + }, + })), + } + }), + }) + } else { + tinyassert(node.declaration.id) + const name = node.declaration.id.name + groups.push({ + type: 'declaration', + node, + declaration: node.declaration, + exports: [ + { + localName: name, + exportName: name, + meta: { + localName: name, + declarationKind: + node.declaration.type === 'FunctionDeclaration' + ? 'function' + : 'class', + isFunction: getIsFunction(node.declaration), + }, + }, + ], + }) + } + } else { + groups.push({ + type: 'specifiers', + node, + exports: node.specifiers.map((specifier) => { + return { + node: specifier, + localName: + specifier.local.type === 'Identifier' + ? specifier.local.name + : String(specifier.local.value), + exportName: + specifier.exported.type === 'Identifier' + ? specifier.exported.name + : String(specifier.exported.value), + meta: {}, + } + }), + }) + } + } else if (node.type === 'ExportAllDeclaration') { + groups.push({ type: 'export-all', node }) + } else if (node.type === 'ExportDefaultDeclaration') { + let localName: string | undefined + let meta: ModuleExportMeta + if ( + (node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'ClassDeclaration') && + node.declaration.id + ) { + localName = node.declaration.id.name + meta = { + localName: node.declaration.id.name, + declarationKind: + node.declaration.type === 'FunctionDeclaration' + ? 'function' + : 'class', + isFunction: getIsFunction(node.declaration), + } + } else { + meta = + node.declaration.type === 'Identifier' + ? { defaultExportIdentifierName: node.declaration.name } + : { isFunction: getIsFunction(node.declaration) } + } + groups.push({ type: 'default', node, localName, meta }) + } + } + + return groups +} + +function getIsFunction( + node: Node | ExportDefaultDeclaration['declaration'], +): boolean | undefined { + if ( + node.type === 'FunctionDeclaration' || + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' + ) { + return true + } + if ( + node.type === 'ClassDeclaration' || + node.type === 'Literal' || + node.type === 'ObjectExpression' || + node.type === 'ArrayExpression' || + node.type === 'ClassExpression' + ) { + return false + } +} diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 69a726c0d..6a10bec93 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -472,4 +472,37 @@ export default cached; expect(actual).toEqual(expected) } }) + + test('reuses export meta across callbacks', async () => { + const input = + 'export const action = async () => {}; export default async () => {}' + const ast = await parseAstAsync(input) + const filtered: unknown[] = [] + const runtime: unknown[] = [] + + transformWrapExport(input, ast, { + filter(_name, meta) { + filtered.push(meta) + return true + }, + runtime(value, _name, meta) { + runtime.push(meta) + return value + }, + }) + + expect(filtered[0]).toBe(filtered[1]) + expect(filtered[0]).toBe(runtime[0]) + expect(Object.keys(filtered[0] as object)).toEqual([ + 'isFunction', + 'declName', + ]) + expect(filtered[2]).toBe(filtered[3]) + expect(filtered[2]).toBe(runtime[1]) + expect(Object.keys(filtered[2] as object)).toEqual([ + 'isFunction', + 'declName', + 'defaultExportIdentifierName', + ]) + }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 07f0f1c30..4415f830b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -1,8 +1,12 @@ import { tinyassert } from '@hiogawa/utils' -import type { ExportDefaultDeclaration, Node, Program } from 'estree' import MagicString from 'magic-string' import type { ESTree } from 'vite' -import { extractNames, validateNonAsyncFunction } from './utils' +import { + scanModuleExports, + type ModuleExportEntry, + type ModuleExportMeta, +} from './module-exports' +import { validateNonAsyncFunction } from './utils' type ExportMeta = { /** @@ -35,8 +39,6 @@ type ExportMeta = { defaultExportIdentifierName?: string } -type ExportWithMeta = { name: string; meta: ExportMeta } - export type TransformWrapExportFilter = ( name: string, meta: ExportMeta, @@ -57,21 +59,29 @@ export function transformWrapExport( exportNames: string[] output: MagicString } { - const ast = viteAst as unknown as Program const output = new MagicString(input) const exportNames: string[] = [] const toAppend: string[] = [] const filter = options.filter ?? (() => true) + const exportMeta = new WeakMap() - function wrapSimple(start: number, end: number, exports: ExportWithMeta[]) { - const filteredExports = exports.map((item) => ({ - ...item, - shouldWrap: filter(item.name, item.meta), - })) + function wrapSimple( + start: number, + end: number, + exports: ModuleExportEntry[], + ) { + const filteredExports = exports.map((item) => { + const meta = getExportMeta(item.meta) + return { + ...item, + meta, + shouldWrap: filter(item.exportName, meta), + } + }) exportNames.push( ...filteredExports .filter((item) => item.shouldWrap) - .map((item) => item.name), + .map((item) => item.exportName), ) // update code and move to preserve `registerServerReference` position // e.g. @@ -85,12 +95,12 @@ export function transformWrapExport( const newCode = filteredExports .map((e) => [ e.shouldWrap && - `${e.name} = /* #__PURE__ */ ${options.runtime( - e.name, - e.name, + `${e.localName} = /* #__PURE__ */ ${options.runtime( + e.localName, + e.exportName, e.meta, )};\n`, - `export { ${e.name} };\n`, + `export { ${e.localName} };\n`, ]) .flat() .filter(Boolean) @@ -116,154 +126,99 @@ export function transformWrapExport( ) } - for (const node of ast.body) { - // named exports - if (node.type === 'ExportNamedDeclaration') { - if (node.declaration) { + for (const group of scanModuleExports(viteAst)) { + if (group.type === 'declaration') { + const [entry] = group.exports + if (filter(entry.exportName, getExportMeta(entry.meta))) { + validateNonAsyncFunction(options, group.declaration) + } + wrapSimple(group.node.start, group.declaration.start, group.exports) + } else if (group.type === 'variable-declaration') { + if (group.declaration.kind === 'const') { + output.update( + group.declaration.start, + group.declaration.start + 5, + 'let', + ) + } + const exports: ModuleExportEntry[] = [] + for (const declarator of group.declarators) { + exports.push(...declarator.exports) if ( - node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration' + declarator.node.init && + declarator.exports.some(({ exportName, meta }) => + filter(exportName, getExportMeta(meta)), + ) ) { - /** - * export function foo() {} - */ - const name = node.declaration.id.name - const meta: ExportMeta = { - isFunction: getIsFunction(node.declaration), - 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 - */ - if (node.declaration.kind === 'const') { - output.update( - node.declaration.start, - node.declaration.start + 5, - 'let', + validateNonAsyncFunction(options, declarator.node.init) + } + } + wrapSimple(group.node.start, group.declaration.start, exports) + } else if (group.type === 'specifiers') { + if (group.node.source) { + output.remove(group.node.start, group.node.end) + for (const entry of group.exports) { + tinyassert(entry.node.local.type === 'Identifier') + if (entry.node.exported.type !== 'Identifier') { + throw Object.assign( + new Error('unsupported string literal export name'), + { pos: entry.node.exported.start }, ) } - const exports: ExportWithMeta[] = [] - for (const decl of node.declaration.declarations) { - const isFunction = - decl.id.type === 'Identifier' && decl.init - ? getIsFunction(decl.init) - : undefined - const declarationExports: ExportWithMeta[] = extractNames( - decl.id, - ).map((name) => ({ - name, - meta: { isFunction, declName: name }, - })) - exports.push(...declarationExports) - if ( - decl.init && - declarationExports.some(({ name, meta }) => filter(name, meta)) - ) { - validateNonAsyncFunction(options, decl.init) - } - } - wrapSimple(node.start, node.declaration.start, exports) - } else { - node.declaration satisfies never + toAppend.push( + `import { ${entry.localName} as $$import_${entry.localName} } from ${group.node.source.raw}`, + ) + wrapExport( + `$$import_${entry.localName}`, + entry.exportName, + getExportMeta(entry.meta), + ) } } else { - if (node.source) { - /** - * export { foo, bar as car } from './foo' - */ - output.remove(node.start, node.end) - for (const spec of node.specifiers) { - tinyassert(spec.local.type === 'Identifier') - if (spec.exported.type !== 'Identifier') { - throw Object.assign( - new Error('unsupported string literal export name'), - { pos: spec.exported.start }, - ) - } - const name = spec.local.name - toAppend.push( - `import { ${name} as $$import_${name} } from ${node.source.raw}`, + output.remove(group.node.start, group.node.end) + for (const entry of group.exports) { + tinyassert(entry.node.local.type === 'Identifier') + if (entry.node.exported.type !== 'Identifier') { + throw Object.assign( + new Error('unsupported string literal export name'), + { pos: entry.node.exported.start }, ) - wrapExport(`$$import_${name}`, spec.exported.name) - } - } else { - /** - * export { foo, bar as car } - */ - output.remove(node.start, node.end) - for (const spec of node.specifiers) { - tinyassert(spec.local.type === 'Identifier') - if (spec.exported.type !== 'Identifier') { - throw Object.assign( - new Error('unsupported string literal export name'), - { pos: spec.exported.start }, - ) - } - wrapExport(spec.local.name, spec.exported.name) } + wrapExport( + entry.localName, + entry.exportName, + getExportMeta(entry.meta), + ) } } - } - - /** - * export * as ns from './foo' - * export * from './foo' - */ - // vue sfc uses ExportAllDeclaration to re-export setup script. - // for now we just give an option to not throw for this case. - // https://github.com/vitejs/vite-plugin-vue/blob/30a97c1ddbdfb0e23b7dc14a1d2fb609668b9987/packages/plugin-vue/src/main.ts#L372 - if (node.type === 'ExportAllDeclaration') { + } else if (group.type === 'export-all') { if (!options.ignoreExportAllDeclaration) { throw Object.assign(new Error('unsupported ExportAllDeclaration'), { - pos: node.start, + pos: group.node.start, }) } - } - - /** - * export default function foo() {} - * export default class Foo {} - * export default () => {} - */ - if (node.type === 'ExportDefaultDeclaration') { - let localName: string - let isFunction: boolean | undefined - let declName: string | undefined - let defaultExportIdentifierName: string | undefined + } else if (group.type === 'default') { + const localName = group.localName ?? '$$default' if ( - (node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration') && - node.declaration.id + (group.node.declaration.type === 'FunctionDeclaration' || + group.node.declaration.type === 'ClassDeclaration') && + group.node.declaration.id ) { // preserve name scope for `function foo() {}` and `class Foo {}` - localName = node.declaration.id.name - output.remove(node.start, node.declaration.start) - isFunction = getIsFunction(node.declaration) - declName = node.declaration.id.name + output.remove(group.node.start, group.node.declaration.start) } else { // otherwise we can introduce new variable - localName = '$$default' - output.update(node.start, node.declaration.start, 'const $$default = ') - if (node.declaration.type === 'Identifier') { - defaultExportIdentifierName = node.declaration.name - } else { - isFunction = getIsFunction(node.declaration) - } - } - const defaultMeta: ExportMeta = { - isFunction, - declName, - defaultExportIdentifierName, + output.update( + group.node.start, + group.node.declaration.start, + 'const $$default = ', + ) } - if (filter('default', defaultMeta)) { - validateNonAsyncFunction(options, node.declaration) + const meta = getExportMeta(group.meta, true) + if (filter('default', meta)) { + validateNonAsyncFunction(options, group.node.declaration) } - wrapExport(localName, 'default', defaultMeta) + wrapExport(localName, 'default', meta) } } @@ -272,25 +227,24 @@ export function transformWrapExport( } return { exportNames, output } -} -function getIsFunction( - node: Node | ExportDefaultDeclaration['declaration'], -): boolean | undefined { - if ( - node.type === 'FunctionDeclaration' || - node.type === 'ArrowFunctionExpression' || - node.type === 'FunctionExpression' - ) { - return true - } - if ( - node.type === 'ClassDeclaration' || - node.type === 'Literal' || - node.type === 'ObjectExpression' || - node.type === 'ArrayExpression' || - node.type === 'ClassExpression' - ) { - return false + function getExportMeta( + meta: ModuleExportMeta, + isDefault = false, + ): ExportMeta { + let result = exportMeta.get(meta) + if (!result) { + result = isDefault + ? { + isFunction: meta.isFunction, + declName: meta.localName, + defaultExportIdentifierName: meta.defaultExportIdentifierName, + } + : meta.localName + ? { isFunction: meta.isFunction, declName: meta.localName } + : {} + exportMeta.set(meta, result) + } + return result } } From 31aea3a3b3904ba721c1de9f6cf1b86c0d58b019 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:47:52 +0900 Subject: [PATCH 02/14] docs(rsc): illustrate module export scan branches Co-authored-by: OpenCode --- .../src/transforms/module-exports.ts | 20 +++++++++++++++++++ .../plugin-rsc/src/transforms/wrap-export.ts | 3 +++ 2 files changed, 23 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index 6c9c97d88..afc24b7fd 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -80,6 +80,9 @@ export function scanModuleExports( if (node.type === 'ExportNamedDeclaration') { if (node.declaration) { if (node.declaration.type === 'VariableDeclaration') { + /** + * export const foo = 1, bar = 2 + */ const declaration = node.declaration groups.push({ type: 'variable-declaration', @@ -105,6 +108,10 @@ export function scanModuleExports( }), }) } else { + /** + * export function foo() {} + * export class Foo {} + */ tinyassert(node.declaration.id) const name = node.declaration.id.name groups.push({ @@ -128,6 +135,10 @@ export function scanModuleExports( }) } } else { + /** + * export { foo, bar as baz } + * export { foo, bar as baz } from './dep' + */ groups.push({ type: 'specifiers', node, @@ -148,8 +159,17 @@ export function scanModuleExports( }) } } else if (node.type === 'ExportAllDeclaration') { + /** + * export * as ns from './dep' + * export * from './dep' + */ groups.push({ type: 'export-all', node }) } else if (node.type === 'ExportDefaultDeclaration') { + /** + * export default function foo() {} + * export default class Foo {} + * export default () => {} + */ let localName: string | undefined let meta: ModuleExportMeta if ( diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 4415f830b..16c81c05b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -192,6 +192,9 @@ export function transformWrapExport( } } } else if (group.type === 'export-all') { + // Vue SFC uses ExportAllDeclaration to re-export its setup script, so + // consumers can opt out of rejecting this form. + // https://github.com/vitejs/vite-plugin-vue/blob/30a97c1ddbdfb0e23b7dc14a1d2fb609668b9987/packages/plugin-vue/src/main.ts#L372 if (!options.ignoreExportAllDeclaration) { throw Object.assign(new Error('unsupported ExportAllDeclaration'), { pos: group.node.start, From f9e095968b0b8411e5285dcf0b72d7c76ca07e89 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:07:26 +0900 Subject: [PATCH 03/14] docs(rsc): explain module export metadata Co-authored-by: OpenCode --- .../src/transforms/module-exports.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index afc24b7fd..97004dcd1 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -14,11 +14,33 @@ import type { ESTree } from 'vite' import { extractNames } from './utils' export type ModuleExportMeta = { + /** + * The local declaration name when statically available. + * + * - `"Page"` for `export function Page() {}` + * - `"Page"` for `export const Page = () => {}` + * - `undefined` for `export default () => {}` + * - `undefined` for `export { Page }` + */ localName?: string + /** The source declaration kind when statically available. */ declarationKind?: 'function' | 'class' | VariableDeclaration['kind'] - /** Whether the exported value is statically known to be a function. */ + /** + * Whether the exported value is statically known to be a function. + * + * - `true` for `export const Page = () => {}` + * - `false` for `export const value = 1` + * - `undefined` for `export const value = getValue()` + * - `undefined` for `export default Page` + */ isFunction?: boolean - /** Local identifier referenced by `export default Identifier`. */ + /** + * The local identifier referenced by a default export. + * + * - `"Page"` for `const Page = () => {}; export default Page` + * - `undefined` for `export default function Page() {}` + * - `undefined` for `export default () => {}` + */ defaultExportIdentifierName?: string } From 378bdaeb61f523e3c9d0d28efd2d4e9681c240e9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:52:30 +0900 Subject: [PATCH 04/14] refactor(rsc): reuse module export scan Co-authored-by: OpenCode --- .../src/transforms/module-export-effect.ts | 234 +++++------------- .../src/transforms/module-exports.test.ts | 4 - .../src/transforms/module-exports.ts | 12 - 3 files changed, 62 insertions(+), 188 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-effect.ts b/packages/plugin-rsc/src/transforms/module-export-effect.ts index 5d208aa9b..7e931cabf 100644 --- a/packages/plugin-rsc/src/transforms/module-export-effect.ts +++ b/packages/plugin-rsc/src/transforms/module-export-effect.ts @@ -1,40 +1,13 @@ import { tinyassert } from '@hiogawa/utils' -import type { ExportDefaultDeclaration, Node, Program } from 'estree' import MagicString from 'magic-string' import type { ESTree } from 'vite' -import { extractNames, validateNonAsyncFunction } from './utils' +import { scanModuleExports, type ModuleExportMeta } from './module-exports' +import { validateNonAsyncFunction } from './utils' // TODO: Metadata, filtering, and returned reference contexts are currently // ported only for transformWrapExport compatibility. Remove them if no // module-export-effect consumer needs this API surface. -export type TransformModuleExportEffectMeta = { - /** - * The local declaration name when statically available. - * - * - `"Page"` for `export function Page() {}` - * - `"Page"` for `export const Page = () => {}` - * - `undefined` for `export default () => {}` - * - `undefined` for `export { Page }` - */ - localName?: string - /** - * Whether the exported value is statically known to be a function. - * - * - `true` for `export const Page = () => {}` - * - `false` for `export const value = 1` - * - `undefined` for `export const value = getValue()` - * - `undefined` for `export default Page` - */ - isFunction?: boolean - /** - * The local identifier referenced by a default export. - * - * - `"Page"` for `const Page = () => {}; export default Page` - * - `undefined` for `export default function Page() {}` - * - `undefined` for `export default () => {}` - */ - defaultExportIdentifierName?: string -} +export type TransformModuleExportEffectMeta = ModuleExportMeta export type TransformModuleExportEffectFilter = ( name: string, @@ -100,7 +73,6 @@ export function transformModuleExportEffect( viteAst: ESTree.Program, options: TransformModuleExportEffectOptions, ): TransformModuleExportEffectResult { - const ast = viteAst as unknown as Program const output = new MagicString(input) const filter = options.filter ?? (() => true) const references: TransformModuleExportEffectContext[] = [] @@ -132,136 +104,75 @@ export function transformModuleExportEffect( } } - for (const node of ast.body) { - if (node.type === 'ExportNamedDeclaration') { - if (node.declaration) { - if ( - node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration' - ) { - /** - * export function foo() {} - * export class Foo {} - */ - tinyassert(node.declaration.id) - const binding = node.declaration.id.name - const meta: TransformModuleExportEffectMeta = { - localName: binding, - isFunction: getIsFunction(node.declaration), + for (const group of scanModuleExports(viteAst)) { + if (group.type === 'declaration') { + const [entry] = group.exports + const { localName: binding, exportName, meta } = entry + if (!filter(exportName, meta)) continue + validateNonAsyncFunction(options, group.declaration) + replaceAndMove( + group.node.start, + group.declaration.start, + input.length, + `${generate({ binding, exportName, meta })}\nexport { ${binding} };`, + ) + } else if (group.type === 'variable-declaration') { + const exportNames: string[] = [] + const effects: string[] = [] + for (const declarator of group.declarators) { + let validate = false + for (const entry of declarator.exports) { + const { localName: binding, exportName, meta } = entry + exportNames.push(exportName) + if (filter(exportName, meta)) { + validate = true + effects.push(generate({ binding, exportName, meta })) } - if (!filter(binding, meta)) continue - validateNonAsyncFunction(options, node.declaration) - replaceAndMove( - node.start, - node.declaration.start, - input.length, - `${generate({ binding, exportName: binding, meta })}\nexport { ${binding} };`, + } + if (validate && declarator.node.init) { + validateNonAsyncFunction(options, declarator.node.init) + } + } + if (effects.length > 0) { + replaceAndMove( + group.node.start, + group.declaration.start, + input.length, + `${effects.join('\n')}\nexport { ${exportNames.join(', ')} };`, + ) + } + } else if (group.type === 'specifiers') { + for (const entry of group.exports) { + tinyassert(entry.node.local.type === 'Identifier') + if (entry.node.exported.type !== 'Identifier') { + throw Object.assign( + new Error('unsupported string literal export name'), + { pos: entry.node.exported.start }, ) - } else if (node.declaration.type === 'VariableDeclaration') { - /** - * export const foo = 1, bar = 2 - */ - const exportNames: string[] = [] - const effects: string[] = [] - for (const declaration of node.declaration.declarations) { - const names = extractNames(declaration.id) - exportNames.push(...names) - const isFunction = - declaration.id.type === 'Identifier' && declaration.init - ? getIsFunction(declaration.init) - : undefined - let validate = false - for (const binding of names) { - const meta: TransformModuleExportEffectMeta = { - localName: binding, - isFunction, - } - if (filter(binding, meta)) { - validate = true - effects.push(generate({ binding, exportName: binding, meta })) - } - } - if (validate && declaration.init) { - validateNonAsyncFunction(options, declaration.init) - } - } - if (effects.length > 0) { - replaceAndMove( - node.start, - node.declaration.start, - input.length, - `${effects.join('\n')}\nexport { ${exportNames.join(', ')} };`, - ) - } } - } else { - /** - * export { foo, bar as baz } - * export { foo, bar as baz } from './dep' - */ - for (const specifier of node.specifiers) { - tinyassert(specifier.local.type === 'Identifier') - if (specifier.exported.type !== 'Identifier') { - throw Object.assign( - new Error('unsupported string literal export name'), - { pos: specifier.exported.start }, - ) - } - const exportName = specifier.exported.name - const meta: TransformModuleExportEffectMeta = {} - if (!filter(exportName, meta)) continue + const { exportName, meta } = entry + if (!filter(exportName, meta)) continue - let binding = specifier.local.name - if (node.source) { - binding = `$$effect_import_${exportName}` - // TODO: Preserve import attributes from the original re-export. - output.append( - `\nimport { ${specifier.local.name} as ${binding} } from ${node.source.raw};`, - ) - } - output.append(`\n${generate({ binding, exportName, meta })}`) + let binding = entry.localName + if (group.node.source) { + binding = `$$effect_import_${exportName}` + // TODO: Preserve import attributes from the original re-export. + output.append( + `\nimport { ${entry.localName} as ${binding} } from ${group.node.source.raw};`, + ) } + output.append(`\n${generate({ binding, exportName, meta })}`) } - } else if (node.type === 'ExportAllDeclaration') { - /** - * export * as ns from './dep' - * export * from './dep' - */ + } else if (group.type === 'export-all') { if (options.exportAll !== 'preserve') { throw Object.assign(new Error('unsupported ExportAllDeclaration'), { - pos: node.start, + pos: group.node.start, }) } - } else if (node.type === 'ExportDefaultDeclaration') { - /** - * export default function foo() {} - * export default class Foo {} - * export default foo - * export default () => {} - */ - let binding: string - let meta: TransformModuleExportEffectMeta - if ( - (node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration') && - node.declaration.id - ) { - // export default function foo() {} - // export default class Foo {} - binding = node.declaration.id.name - meta = { - localName: binding, - isFunction: getIsFunction(node.declaration), - } - } else if (node.declaration.type === 'Identifier') { - // export default foo - binding = '$$effect_default' - meta = { defaultExportIdentifierName: node.declaration.name } - } else { - // export default () => {} - binding = '$$effect_default' - meta = { isFunction: getIsFunction(node.declaration) } - } + } else if (group.type === 'default') { + const node = group.node + const binding = group.localName ?? '$$effect_default' + const meta = group.meta if (!filter('default', meta)) continue validateNonAsyncFunction(options, node.declaration) const effect = generate({ binding, exportName: 'default', meta }) @@ -335,24 +246,3 @@ export function transformModuleExportEffect( referenceNames: references.map((reference) => reference.exportName), } } - -function getIsFunction( - node: Node | ExportDefaultDeclaration['declaration'], -): boolean | undefined { - if ( - node.type === 'FunctionDeclaration' || - node.type === 'FunctionExpression' || - node.type === 'ArrowFunctionExpression' - ) { - return true - } - if ( - node.type === 'ClassDeclaration' || - node.type === 'Literal' || - node.type === 'ObjectExpression' || - node.type === 'ArrayExpression' || - node.type === 'ClassExpression' - ) { - return false - } -} diff --git a/packages/plugin-rsc/src/transforms/module-exports.test.ts b/packages/plugin-rsc/src/transforms/module-exports.test.ts index aeaeb025c..70685b2b1 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.test.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.test.ts @@ -25,7 +25,6 @@ export * from './all' exportName: 'action', meta: { localName: 'action', - declarationKind: 'function', isFunction: true, }, }, @@ -42,7 +41,6 @@ export * from './all' exportName: 'loader', meta: { localName: 'loader', - declarationKind: 'const', isFunction: true, }, }, @@ -55,7 +53,6 @@ export * from './all' exportName: 'value', meta: { localName: 'value', - declarationKind: 'const', isFunction: false, }, }, @@ -73,7 +70,6 @@ export * from './all' exportName: 'item', meta: { localName: 'item', - declarationKind: 'const', isFunction: undefined, }, }, diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index 97004dcd1..031d48e67 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -7,7 +7,6 @@ import type { ClassDeclaration, Node, Program, - VariableDeclaration, VariableDeclarator, } from 'estree' import type { ESTree } from 'vite' @@ -23,8 +22,6 @@ export type ModuleExportMeta = { * - `undefined` for `export { Page }` */ localName?: string - /** The source declaration kind when statically available. */ - declarationKind?: 'function' | 'class' | VariableDeclaration['kind'] /** * Whether the exported value is statically known to be a function. * @@ -122,7 +119,6 @@ export function scanModuleExports( exportName: name, meta: { localName: name, - declarationKind: declaration.kind, isFunction, }, })), @@ -146,10 +142,6 @@ export function scanModuleExports( exportName: name, meta: { localName: name, - declarationKind: - node.declaration.type === 'FunctionDeclaration' - ? 'function' - : 'class', isFunction: getIsFunction(node.declaration), }, }, @@ -202,10 +194,6 @@ export function scanModuleExports( localName = node.declaration.id.name meta = { localName: node.declaration.id.name, - declarationKind: - node.declaration.type === 'FunctionDeclaration' - ? 'function' - : 'class', isFunction: getIsFunction(node.declaration), } } else { From 99b21499a60c83093162e562a89f02d53036b725 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:54:34 +0900 Subject: [PATCH 05/14] refactor(rsc): simplify export metadata conversion Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 33 ------------------- .../plugin-rsc/src/transforms/wrap-export.ts | 31 +++++++---------- 2 files changed, 11 insertions(+), 53 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 6a10bec93..69a726c0d 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -472,37 +472,4 @@ export default cached; expect(actual).toEqual(expected) } }) - - test('reuses export meta across callbacks', async () => { - const input = - 'export const action = async () => {}; export default async () => {}' - const ast = await parseAstAsync(input) - const filtered: unknown[] = [] - const runtime: unknown[] = [] - - transformWrapExport(input, ast, { - filter(_name, meta) { - filtered.push(meta) - return true - }, - runtime(value, _name, meta) { - runtime.push(meta) - return value - }, - }) - - expect(filtered[0]).toBe(filtered[1]) - expect(filtered[0]).toBe(runtime[0]) - expect(Object.keys(filtered[0] as object)).toEqual([ - 'isFunction', - 'declName', - ]) - expect(filtered[2]).toBe(filtered[3]) - expect(filtered[2]).toBe(runtime[1]) - expect(Object.keys(filtered[2] as object)).toEqual([ - 'isFunction', - 'declName', - 'defaultExportIdentifierName', - ]) - }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 16c81c05b..02de26550 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -63,7 +63,6 @@ export function transformWrapExport( const exportNames: string[] = [] const toAppend: string[] = [] const filter = options.filter ?? (() => true) - const exportMeta = new WeakMap() function wrapSimple( start: number, @@ -230,24 +229,16 @@ export function transformWrapExport( } return { exportNames, output } +} - function getExportMeta( - meta: ModuleExportMeta, - isDefault = false, - ): ExportMeta { - let result = exportMeta.get(meta) - if (!result) { - result = isDefault - ? { - isFunction: meta.isFunction, - declName: meta.localName, - defaultExportIdentifierName: meta.defaultExportIdentifierName, - } - : meta.localName - ? { isFunction: meta.isFunction, declName: meta.localName } - : {} - exportMeta.set(meta, result) - } - return result - } +function getExportMeta(meta: ModuleExportMeta, isDefault = false): ExportMeta { + return isDefault + ? { + isFunction: meta.isFunction, + declName: meta.localName, + defaultExportIdentifierName: meta.defaultExportIdentifierName, + } + : meta.localName + ? { isFunction: meta.isFunction, declName: meta.localName } + : {} } From 7d624cbe658595619e15be904ff7f6068c256ec0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:56:22 +0900 Subject: [PATCH 06/14] refactor(rsc): simplify export meta adapter Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/wrap-export.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 02de26550..188147353 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -216,7 +216,7 @@ export function transformWrapExport( 'const $$default = ', ) } - const meta = getExportMeta(group.meta, true) + const meta = getExportMeta(group.meta) if (filter('default', meta)) { validateNonAsyncFunction(options, group.node.declaration) } @@ -231,14 +231,6 @@ export function transformWrapExport( return { exportNames, output } } -function getExportMeta(meta: ModuleExportMeta, isDefault = false): ExportMeta { - return isDefault - ? { - isFunction: meta.isFunction, - declName: meta.localName, - defaultExportIdentifierName: meta.defaultExportIdentifierName, - } - : meta.localName - ? { isFunction: meta.isFunction, declName: meta.localName } - : {} +function getExportMeta({ localName, ...meta }: ModuleExportMeta): ExportMeta { + return { ...meta, ...(localName && { declName: localName }) } } From 820411eaec8b5b31d9f0ca329d8ad7a466f75fa1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:05:14 +0900 Subject: [PATCH 07/14] refactor(rsc): share module export metadata type Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/index.ts | 1 + packages/plugin-rsc/src/transforms/module-export-effect.ts | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/index.ts b/packages/plugin-rsc/src/transforms/index.ts index 6147430da..8ad495d3c 100644 --- a/packages/plugin-rsc/src/transforms/index.ts +++ b/packages/plugin-rsc/src/transforms/index.ts @@ -1,4 +1,5 @@ export * from './hoist' +export type { ModuleExportMeta } from './module-exports' export * from './module-export-effect' export * from './wrap-export' export * from './proxy-export' diff --git a/packages/plugin-rsc/src/transforms/module-export-effect.ts b/packages/plugin-rsc/src/transforms/module-export-effect.ts index 7e931cabf..249d13205 100644 --- a/packages/plugin-rsc/src/transforms/module-export-effect.ts +++ b/packages/plugin-rsc/src/transforms/module-export-effect.ts @@ -7,17 +7,15 @@ import { validateNonAsyncFunction } from './utils' // TODO: Metadata, filtering, and returned reference contexts are currently // ported only for transformWrapExport compatibility. Remove them if no // module-export-effect consumer needs this API surface. -export type TransformModuleExportEffectMeta = ModuleExportMeta - export type TransformModuleExportEffectFilter = ( name: string, - meta: TransformModuleExportEffectMeta, + meta: ModuleExportMeta, ) => boolean export type TransformModuleExportEffectContext = { binding: string exportName: string - meta: TransformModuleExportEffectMeta + meta: ModuleExportMeta } export type TransformModuleExportEffectOptions = { From 5511f4dfdc066726b1517b978526befd09c6c077 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:09:07 +0900 Subject: [PATCH 08/14] docs(rsc): document module export group variants Co-authored-by: OpenCode --- .../src/transforms/module-exports.ts | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index 031d48e67..ca2597dbc 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -56,12 +56,19 @@ export type ModuleExportSpecifier = { export type ModuleExportGroup = | { + /** + * export function foo() {} + * export class Foo {} + */ type: 'declaration' node: ExportNamedDeclaration declaration: FunctionDeclaration | ClassDeclaration exports: [ModuleExportEntry] } | { + /** + * export const foo = 1, bar = 2 + */ type: 'variable-declaration' node: ExportNamedDeclaration declaration: Extract< @@ -74,15 +81,27 @@ export type ModuleExportGroup = }[] } | { + /** + * export { foo as bar } + * export { foo as bar } from './dep' + */ type: 'specifiers' node: ExportNamedDeclaration exports: ModuleExportSpecifier[] } | { + /** + * export * from './dep' + * export * as ns from './dep' + */ type: 'export-all' node: Extract } | { + /** + * export default function foo() {} + * export default value + */ type: 'default' node: ExportDefaultDeclaration localName?: string @@ -99,9 +118,6 @@ export function scanModuleExports( if (node.type === 'ExportNamedDeclaration') { if (node.declaration) { if (node.declaration.type === 'VariableDeclaration') { - /** - * export const foo = 1, bar = 2 - */ const declaration = node.declaration groups.push({ type: 'variable-declaration', @@ -126,10 +142,6 @@ export function scanModuleExports( }), }) } else { - /** - * export function foo() {} - * export class Foo {} - */ tinyassert(node.declaration.id) const name = node.declaration.id.name groups.push({ @@ -149,10 +161,6 @@ export function scanModuleExports( }) } } else { - /** - * export { foo, bar as baz } - * export { foo, bar as baz } from './dep' - */ groups.push({ type: 'specifiers', node, @@ -173,17 +181,8 @@ export function scanModuleExports( }) } } else if (node.type === 'ExportAllDeclaration') { - /** - * export * as ns from './dep' - * export * from './dep' - */ groups.push({ type: 'export-all', node }) } else if (node.type === 'ExportDefaultDeclaration') { - /** - * export default function foo() {} - * export default class Foo {} - * export default () => {} - */ let localName: string | undefined let meta: ModuleExportMeta if ( From dd40175b443c8322788cdddff4f9e75db9ea09e7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:11:50 +0900 Subject: [PATCH 09/14] refactor(rsc): simplify export group types Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-exports.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index ca2597dbc..88b93b55f 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -1,5 +1,6 @@ import { tinyassert } from '@hiogawa/utils' import type { + ExportAllDeclaration, ExportDefaultDeclaration, ExportNamedDeclaration, ExportSpecifier, @@ -7,6 +8,7 @@ import type { ClassDeclaration, Node, Program, + VariableDeclaration, VariableDeclarator, } from 'estree' import type { ESTree } from 'vite' @@ -71,10 +73,7 @@ export type ModuleExportGroup = */ type: 'variable-declaration' node: ExportNamedDeclaration - declaration: Extract< - ExportNamedDeclaration['declaration'], - { type: 'VariableDeclaration' } - > + declaration: VariableDeclaration declarators: { node: VariableDeclarator exports: ModuleExportEntry[] @@ -95,7 +94,7 @@ export type ModuleExportGroup = * export * as ns from './dep' */ type: 'export-all' - node: Extract + node: ExportAllDeclaration } | { /** From 56375c63d41129d5348ed9e4192273aad45e50b7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:22:25 +0900 Subject: [PATCH 10/14] docs(rsc): illustrate export scan branches Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-exports.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index 88b93b55f..dee37ec77 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -117,6 +117,7 @@ export function scanModuleExports( if (node.type === 'ExportNamedDeclaration') { if (node.declaration) { if (node.declaration.type === 'VariableDeclaration') { + // export const foo = 1, bar = 2 const declaration = node.declaration groups.push({ type: 'variable-declaration', @@ -141,6 +142,8 @@ export function scanModuleExports( }), }) } else { + // export function foo() {} + // export class Foo {} tinyassert(node.declaration.id) const name = node.declaration.id.name groups.push({ @@ -160,6 +163,8 @@ export function scanModuleExports( }) } } else { + // export { foo as bar } + // export { foo as bar } from './dep' groups.push({ type: 'specifiers', node, @@ -180,8 +185,12 @@ export function scanModuleExports( }) } } else if (node.type === 'ExportAllDeclaration') { + // export * from './dep' + // export * as ns from './dep' groups.push({ type: 'export-all', node }) } else if (node.type === 'ExportDefaultDeclaration') { + // export default function foo() {} + // export default value let localName: string | undefined let meta: ModuleExportMeta if ( From 5f3593b8d36007cfc5d630397dd2685a299986e1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:29:07 +0900 Subject: [PATCH 11/14] refactor(rsc): flag unsupported string exports Co-authored-by: OpenCode --- .../src/transforms/module-exports.test.ts | 19 ++++++++++++++++--- .../src/transforms/module-exports.ts | 6 ++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-exports.test.ts b/packages/plugin-rsc/src/transforms/module-exports.test.ts index 70685b2b1..85ae1f5ef 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.test.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.test.ts @@ -95,8 +95,11 @@ export * from './all' expect(groups[6]).toMatchObject({ type: 'export-all' }) }) -test('preserves string literal export names', async () => { - const ast = await parseAstAsync(`export { local as "public name" }`) +test('flags string literal export names as unsupported', async () => { + const ast = await parseAstAsync(` +export { local as "public name" } +export { "remote name" as remote } from './dep' +`) expect(scanModuleExports(ast)).toMatchObject([ { @@ -104,10 +107,20 @@ test('preserves string literal export names', async () => { exports: [ { localName: 'local', - exportName: 'public name', + exportName: '__unsupported_string_export__', node: { exported: { type: 'Literal' } }, }, ], }, + { + type: 'specifiers', + exports: [ + { + localName: '__unsupported_string_export__', + exportName: 'remote', + node: { local: { type: 'Literal' } }, + }, + ], + }, ]) }) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index dee37ec77..cc44ef164 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -169,16 +169,18 @@ export function scanModuleExports( type: 'specifiers', node, exports: node.specifiers.map((specifier) => { + // String-literal export names are unsupported. Callers must check + // the returned node's local and exported types before rewriting. return { node: specifier, localName: specifier.local.type === 'Identifier' ? specifier.local.name - : String(specifier.local.value), + : '__unsupported_string_export__', exportName: specifier.exported.type === 'Identifier' ? specifier.exported.name - : String(specifier.exported.value), + : '__unsupported_string_export__', meta: {}, } }), From 1cec231452bd43816d4d9a49facd5e81387b5435 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:36:29 +0900 Subject: [PATCH 12/14] refactor(rsc): export module export scanner Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/index.ts | 2 +- packages/plugin-rsc/src/transforms/wrap-export.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/transforms/index.ts b/packages/plugin-rsc/src/transforms/index.ts index 8ad495d3c..7a185a783 100644 --- a/packages/plugin-rsc/src/transforms/index.ts +++ b/packages/plugin-rsc/src/transforms/index.ts @@ -1,5 +1,5 @@ export * from './hoist' -export type { ModuleExportMeta } from './module-exports' +export * from './module-exports' export * from './module-export-effect' export * from './wrap-export' export * from './proxy-export' diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 188147353..8820fa54f 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -207,9 +207,20 @@ export function transformWrapExport( group.node.declaration.id ) { // preserve name scope for `function foo() {}` and `class Foo {}` + // e.g. + // export default foo() {} + // ^^^^^^^^^^^^^^ + //. ⬇️ (remove `export default`) + // function foo() {} output.remove(group.node.start, group.node.declaration.start) } else { // otherwise we can introduce new variable + // e.g. + // export default foo + // ^^^^^^^^^^^^^^ + //. ⬇️ (replace `export default`) + // const $$default = foo + // ^^^^^^^^^^^^^^^^^ output.update( group.node.start, group.node.declaration.start, From aced5b5aa531e90849d89d92d85ed74669a258e4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:45:03 +0900 Subject: [PATCH 13/14] refactor(rsc): classify default exports during scan Co-authored-by: OpenCode --- .../src/transforms/module-export-effect.ts | 18 +++++++++++------- .../src/transforms/module-exports.test.ts | 11 +++++++++++ .../src/transforms/module-exports.ts | 16 +++++++++++----- .../plugin-rsc/src/transforms/wrap-export.ts | 6 +----- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-effect.ts b/packages/plugin-rsc/src/transforms/module-export-effect.ts index 249d13205..5a051dd0c 100644 --- a/packages/plugin-rsc/src/transforms/module-export-effect.ts +++ b/packages/plugin-rsc/src/transforms/module-export-effect.ts @@ -1,4 +1,5 @@ import { tinyassert } from '@hiogawa/utils' +import type { Identifier } from 'estree' import MagicString from 'magic-string' import type { ESTree } from 'vite' import { scanModuleExports, type ModuleExportMeta } from './module-exports' @@ -175,7 +176,8 @@ export function transformModuleExportEffect( validateNonAsyncFunction(options, node.declaration) const effect = generate({ binding, exportName: 'default', meta }) - if (node.declaration.type === 'Identifier') { + if (group.kind === 'identifier') { + const declaration = node.declaration as Identifier // export default foo // ^^^^^^^^^^^ // ⬇️ (replace `default foo`) @@ -185,7 +187,7 @@ export function transformModuleExportEffect( output.update( exportTokenEnd, node.end, - `const ${binding} = ${node.declaration.name};`, + `const ${binding} = ${declaration.name};`, ) // export const $$effect_default = foo // ^^^^^^ @@ -199,11 +201,13 @@ export function transformModuleExportEffect( input.length, `${effect}\nexport default ${binding};`, ) - } else if ( - (node.declaration.type === 'FunctionDeclaration' || - node.declaration.type === 'ClassDeclaration') && - node.declaration.id - ) { + } else if (group.kind === 'named-declaration') { + // export default function foo() {} + // ^^^^^^^^^^^^^^ + // ⬇️ + // function foo() {} + // registerServerReference(foo, 'default'); // << effect + // export default foo; // << export replaceAndMove( node.start, node.declaration.start, diff --git a/packages/plugin-rsc/src/transforms/module-exports.test.ts b/packages/plugin-rsc/src/transforms/module-exports.test.ts index 85ae1f5ef..db83d638a 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.test.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.test.ts @@ -89,12 +89,23 @@ export * from './all' }) expect(groups[5]).toMatchObject({ type: 'default', + kind: 'identifier', localName: undefined, meta: { defaultExportIdentifierName: 'action' }, }) expect(groups[6]).toMatchObject({ type: 'export-all' }) }) +test.each([ + ['export default function action() {}', 'named-declaration'], + ['export default action', 'identifier'], + ['export default () => {}', 'other'], +] as const)('classifies %s', async (source, kind) => { + const ast = await parseAstAsync(source) + + expect(scanModuleExports(ast)).toMatchObject([{ type: 'default', kind }]) +}) + test('flags string literal export names as unsupported', async () => { const ast = await parseAstAsync(` export { local as "public name" } diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index cc44ef164..c77881776 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -56,6 +56,8 @@ export type ModuleExportSpecifier = { meta: ModuleExportMeta } +type ModuleExportDefaultKind = 'named-declaration' | 'identifier' | 'other' + export type ModuleExportGroup = | { /** @@ -102,6 +104,7 @@ export type ModuleExportGroup = * export default value */ type: 'default' + kind: ModuleExportDefaultKind node: ExportDefaultDeclaration localName?: string meta: ModuleExportMeta @@ -193,6 +196,7 @@ export function scanModuleExports( } else if (node.type === 'ExportDefaultDeclaration') { // export default function foo() {} // export default value + let kind: ModuleExportDefaultKind let localName: string | undefined let meta: ModuleExportMeta if ( @@ -200,18 +204,20 @@ export function scanModuleExports( node.declaration.type === 'ClassDeclaration') && node.declaration.id ) { + kind = 'named-declaration' localName = node.declaration.id.name meta = { localName: node.declaration.id.name, isFunction: getIsFunction(node.declaration), } + } else if (node.declaration.type === 'Identifier') { + kind = 'identifier' + meta = { defaultExportIdentifierName: node.declaration.name } } else { - meta = - node.declaration.type === 'Identifier' - ? { defaultExportIdentifierName: node.declaration.name } - : { isFunction: getIsFunction(node.declaration) } + kind = 'other' + meta = { isFunction: getIsFunction(node.declaration) } } - groups.push({ type: 'default', node, localName, meta }) + groups.push({ type: 'default', kind, node, localName, meta }) } } diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 8820fa54f..bf383f65f 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -201,11 +201,7 @@ export function transformWrapExport( } } else if (group.type === 'default') { const localName = group.localName ?? '$$default' - if ( - (group.node.declaration.type === 'FunctionDeclaration' || - group.node.declaration.type === 'ClassDeclaration') && - group.node.declaration.id - ) { + if (group.kind === 'named-declaration') { // preserve name scope for `function foo() {}` and `class Foo {}` // e.g. // export default foo() {} From e800ab1e0d7c3762684b8a1a4e67a59c6d6c3f15 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:46:42 +0900 Subject: [PATCH 14/14] docs(rsc): illustrate default export kinds Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/module-exports.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-exports.ts b/packages/plugin-rsc/src/transforms/module-exports.ts index c77881776..c3b8ccf2a 100644 --- a/packages/plugin-rsc/src/transforms/module-exports.ts +++ b/packages/plugin-rsc/src/transforms/module-exports.ts @@ -100,8 +100,10 @@ export type ModuleExportGroup = } | { /** - * export default function foo() {} - * export default value + * `named-declaration`: export default function foo() {} + * `identifier`: export default value + * `other`: export default function () {} + * `other`: export default () => {} */ type: 'default' kind: ModuleExportDefaultKind @@ -214,6 +216,8 @@ export function scanModuleExports( kind = 'identifier' meta = { defaultExportIdentifierName: node.declaration.name } } else { + // export default function () {} + // export default () => {} kind = 'other' meta = { isFunction: getIsFunction(node.declaration) } }