From 9fb85ab45aae8a6bc7742aabf85f7332a5dd0cd1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:19:49 +0900 Subject: [PATCH 1/9] fix(rsc): avoid rewriting filtered exports Select exports before mutating their declarations, preserve skipped specifiers, and validate selected uninitialized bindings. Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 91 ++++-- .../plugin-rsc/src/transforms/wrap-export.ts | 266 ++++++++++++------ 2 files changed, 251 insertions(+), 106 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 976d511a9..d3e19e413 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -247,6 +247,70 @@ export { a as aa }; `) }) + test.each([ + `export function action() {}`, + `export const action = () => {}`, + `const action = () => {}; export { action }`, + `export { action } from './dep'`, + `export default () => {}`, + ])('does not rewrite fully filtered export groups: %s', async (input) => { + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + filter: () => false, + }) + + expect(result.exportNames).toEqual([]) + expect(result.output.hasChanged()).toBe(false) + expect(result.output.toString()).toBe(input) + }) + + test('preserves filtered export specifiers', async () => { + const input = `\ +const selected = 0, skipped = 1 +export { selected, skipped as renamed } +export { remote, ignored as forwarded } from './dep' +` + const result = await testTransform(input, { + filter: (name) => name === 'selected' || name === 'remote', + }) + + expect(result).toMatchInlineSnapshot(` + "const selected = 0, skipped = 1 + + + ; + const $$wrap_selected = /* #__PURE__ */ $$wrap(selected, \"\", \"selected\"); + export { $$wrap_selected as selected }; + export { skipped as renamed }; + import { remote as $$import_remote } from './dep'; + const $$wrap_$$import_remote = /* #__PURE__ */ $$wrap($$import_remote, \"\", \"remote\"); + export { $$wrap_$$import_remote as remote }; + export { ignored as forwarded } from './dep'; + " + `) + }) + + test('rejects selected uninitialized variables', async () => { + const input = `export let selected, skipped` + const ast = await parseAstAsync(input) + + expect(() => + transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + rejectNonAsyncFunction: true, + filter: (name) => name === 'selected', + }), + ).toThrow('unsupported non async function') + + const filtered = transformWrapExport(input, ast, { + runtime: (value) => `$$wrap(${value})`, + rejectNonAsyncFunction: true, + filter: () => false, + }) + expect(filtered.output.hasChanged()).toBe(false) + }) + test('filter meta', async () => { const input = ` export const a = 0; @@ -259,11 +323,10 @@ export default function d() {} }) expect(result).toMatchInlineSnapshot(` " - let a = 0; + export const a = 0; let b = function() {} let c = () => {} function d() {} - export { a }; b = /* #__PURE__ */ $$wrap(b, "", "b"); export { b }; c = /* #__PURE__ */ $$wrap(c, "", "c"); @@ -282,13 +345,7 @@ export default () => {} const result = await testTransform(input, { filter: (_name, meta) => !!(meta.isFunction && meta.declName), }) - expect(result).toMatchInlineSnapshot(` - " - const $$default = () => {} - ; - export { $$default as default }; - " - `) + expect(result).toMatchInlineSnapshot(`false`) }) test('filter defaultExportIdentifierName', async () => { @@ -329,12 +386,10 @@ export const tags = [] expect(result.exportNames).toEqual(['action']) expect(result.output.toString()).toMatchInlineSnapshot(` "let action = async () => {} - let metadata = {} - let tags = [] + export const metadata = {} + export const tags = [] action = /* #__PURE__ */ $$wrap(action, "action"); export { action }; - export { metadata }; - export { tags }; " `) }) @@ -353,9 +408,8 @@ export default async function Page() {} expect(result.exportNames).toEqual(['default']) expect(result.output.toString()).toMatchInlineSnapshot(` " - let revalidate = 1; + export const revalidate = 1; async function Page() {} - export { revalidate }; ; const $$wrap_Page = /* #__PURE__ */ $$wrap(Page, "default"); export { $$wrap_Page as default }; @@ -372,11 +426,8 @@ export default async function Page() {} filter: () => false, }) expect(result.exportNames).toEqual([]) - expect(result.output.toString()).toMatchInlineSnapshot(` - "const $$default = 1;; - export { $$default as default }; - " - `) + expect(result.output.hasChanged()).toBe(false) + expect(result.output.toString()).toBe(input) }) test('unknown identifier exports remain eligible for wrapping', async () => { diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 25b0320d4..f48a645de 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -6,7 +6,7 @@ import { type ModuleExportEntry, type ModuleExportMeta, } from './module-export-scan' -import { validateNonAsyncFunction } from './utils' +import { rejectNonAsyncFunction, validateNonAsyncFunction } from './utils' export type TransformWrapExportFilter = ( name: string, @@ -20,6 +20,43 @@ export type TransformWrapExportOptions = { filter?: TransformWrapExportFilter } +/** + * Replaces selected module-local export bindings with runtime wrappers. + * + * Conceptually: + * + * ```js + * export async function action() {} + * export const loader = async () => {} + * export default function Page() {} + * ``` + * + * becomes: + * + * ```js + * async function action() {} + * let loader = async () => {} + * function Page() {} + * + * action = __WRAP__(action, 'action') + * export { action } + * loader = __WRAP__(loader, 'loader') + * export { loader } + * const $$wrap_Page = __WRAP__(Page, 'default') + * export { $$wrap_Page as default } + * ``` + * + * Here, `__WRAP__(...)` represents the expression returned by `runtime`. + * Unlike `transformModuleExportWrap`, direct local references observe the + * wrapped value because the original binding itself is reassigned. + * + * Declaration rewrites are moved to the end of the module so each generated + * runtime call retains the original export token's source mapping. Wrappers for + * export specifiers and default exports are appended without explicit mappings. + * + * Generated `$$wrap_*` and `$$import_*` names are not deconflicted from user + * bindings, consistent with the other transform helpers. + */ export function transformWrapExport( input: string, viteAst: ESTree.Program, @@ -30,37 +67,38 @@ export function transformWrapExport( } { const output = new MagicString(input) const exportNames: string[] = [] - const toAppend: string[] = [] + const appendedCode: string[] = [] const filter = options.filter ?? (() => true) - function wrapSimple( + /** + * Strips a direct export declaration and emits assignments and exports at + * module end. All bindings are re-exported, but only selected bindings are + * assigned runtime wrappers. + * + * The rewritten source range is moved so generated runtime calls borrow the + * original export site's mapping: + * + * ```js + * // input + * export async function action() {} + * ^^^^^^ + * + * // output + * async function action() {} + * action = __WRAP__(action, 'action') // maps to the `export` token + * export { action } + * ``` + */ + function rewriteDirectExports( start: number, end: number, exports: ModuleExportEntry[], + selectedExportNames: Set, ) { - const filteredExports = exports.map((item) => { - return { - ...item, - shouldWrap: filter(item.exportName, item.meta), - } - }) - exportNames.push( - ...filteredExports - .filter((item) => item.shouldWrap) - .map((item) => item.exportName), - ) - // update code and move to preserve `registerServerReference` position - // e.g. - // input - // export async function f() {} - // ^^^^^^ - // output - // async function f() {} - // f = registerServerReference(f, ...) << maps to original "export" token - // export { f } << - const newCode = filteredExports + exportNames.push(...selectedExportNames) + const newCode = exports .map((e) => [ - e.shouldWrap && + selectedExportNames.has(e.exportName) && `${e.localName} = /* #__PURE__ */ ${options.runtime( e.localName, e.exportName, @@ -75,18 +113,27 @@ export function transformWrapExport( output.move(start, end, input.length) } - function wrapExport( + /** + * Emits a separate wrapper binding for exports that cannot reassign an + * existing direct declaration, such as export specifiers and defaults. + * + * ```js + * // existing source binding remains unchanged + * const local = init() + * + * // appended code + * const $$wrap_local = __WRAP__(local, 'renamed') + * export { $$wrap_local as renamed } + * ``` + */ + function emitWrappedBinding( name: string, exportName: string, meta: ModuleExportMeta = {}, ) { - if (!filter(exportName, meta)) { - toAppend.push(`export { ${name} as ${exportName} }`) - return - } exportNames.push(exportName) - toAppend.push( + appendedCode.push( `const $$wrap_${name} = /* #__PURE__ */ ${options.runtime( name, exportName, @@ -98,12 +145,38 @@ export function transformWrapExport( for (const group of scanModuleExports(viteAst)) { if (group.type === 'declaration') { + // export async function action() {} + // ^^^^^^ + // becomes a local declaration followed by a wrapper assignment and + // re-export at module end. A filtered declaration remains untouched. const [entry] = group.exports - if (filter(entry.exportName, entry.meta)) { - validateNonAsyncFunction(options, group.declaration) - } - wrapSimple(group.node.start, group.declaration.start, group.exports) + if (!filter(entry.exportName, entry.meta)) continue + validateNonAsyncFunction(options, group.declaration) + rewriteDirectExports( + group.node.start, + group.declaration.start, + group.exports, + new Set([entry.exportName]), + ) } else if (group.type === 'variable-declaration') { + // export const selected = init(), skipped = init() + // ^^^^^^^^^^^^ + // becomes: + // let selected = init(), skipped = init() + // selected = __WRAP__(selected, 'selected') + // export { selected } + // export { skipped } + // + // `const` must become `let` because selected bindings are reassigned. If + // the entire declaration is filtered out, it remains unchanged. + const exports = group.declarators.flatMap((item) => item.exports) + const selectedExportNames = new Set( + exports + .filter((entry) => filter(entry.exportName, entry.meta)) + .map((entry) => entry.exportName), + ) + if (selectedExportNames.size === 0) continue + if (group.declaration.kind === 'const') { output.update( group.declaration.start, @@ -111,52 +184,75 @@ export function transformWrapExport( 'let', ) } - const exports: ModuleExportEntry[] = [] for (const declarator of group.declarators) { - exports.push(...declarator.exports) if ( - declarator.node.init && - declarator.exports.some(({ exportName, meta }) => - filter(exportName, meta), + declarator.exports.some(({ exportName }) => + selectedExportNames.has(exportName), ) ) { - validateNonAsyncFunction(options, declarator.node.init) + if (declarator.node.init) { + validateNonAsyncFunction(options, declarator.node.init) + } else { + rejectNonAsyncFunction(options, declarator.node.start) + } } } - wrapSimple(group.node.start, group.declaration.start, exports) + rewriteDirectExports( + group.node.start, + group.declaration.start, + exports, + selectedExportNames, + ) } 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 }, - ) - } - toAppend.push( + // export { selected as action, skipped } + // becomes an appended wrapper export for `action` plus a preserved export + // for `skipped`. Filtered specifiers are not routed through generated + // bindings, and a fully filtered statement remains untouched. + 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 selectedExports = group.exports.filter((entry) => + filter(entry.exportName, entry.meta), + ) + if (selectedExports.length === 0) continue + + output.remove(group.node.start, group.node.end) + const skippedExports: string[] = [] + for (const entry of group.exports) { + if (!selectedExports.includes(entry)) { + skippedExports.push( + entry.localName === entry.exportName + ? entry.localName + : `${entry.localName} as ${entry.exportName}`, + ) + continue + } + if (group.node.source) { + // export { remote as action } from './dep' + // becomes an import binding followed by its wrapped export. Importing + // gives the runtime callback a local implementation expression. + appendedCode.push( `import { ${entry.localName} as $$import_${entry.localName} } from ${group.node.source.raw}`, ) - wrapExport( + emitWrappedBinding( `$$import_${entry.localName}`, entry.exportName, entry.meta, ) - } - } else { - 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(entry.localName, entry.exportName, entry.meta) + } else { + emitWrappedBinding(entry.localName, entry.exportName, entry.meta) } } + if (skippedExports.length > 0) { + const source = group.node.source ? ` from ${group.node.source.raw}` : '' + appendedCode.push(`export { ${skippedExports.join(', ')} }${source}`) + } } 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. @@ -167,39 +263,37 @@ export function transformWrapExport( }) } } else if (group.type === 'default') { + const meta = group.meta + if (!filter('default', meta)) continue + const localName = group.localName ?? '$$default' if (group.kind === 'named-declaration') { - // preserve name scope for `function foo() {}` and `class Foo {}` - // e.g. - // export default foo() {} - // ^^^^^^^^^^^^^^ - //. ⬇️ (remove `export default`) - // function foo() {} + // export default function Page() {} + // ^^^^^^^^^^^^^^^ + // becomes a named local declaration followed by a separate wrapper + // binding and default export. Keeping the declaration preserves its + // module-local name and scope. 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 - // ^^^^^^^^^^^^^^^^^ + // export default expression + // ^^^^^^^^^^^^^^^ + // becomes `const $$default = expression`, which gives anonymous and + // arbitrary default values a local implementation binding to wrap. output.update( group.node.start, group.node.declaration.start, 'const $$default = ', ) } - const meta = group.meta - if (filter('default', meta)) { - validateNonAsyncFunction(options, group.node.declaration) - } - wrapExport(localName, 'default', meta) + validateNonAsyncFunction(options, group.node.declaration) + emitWrappedBinding(localName, 'default', meta) } } - if (toAppend.length > 0) { - output.append(['', ...toAppend, ''].join(';\n')) + // Emit wrapper bindings, reconstructed skipped exports, and imports for + // selected forwarded exports in their discovery order. + if (appendedCode.length > 0) { + output.append(['', ...appendedCode, ''].join(';\n')) } return { exportNames, output } From 4b55c1bfcf01d0d2528ec97ad7949a474a6980e7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:29:36 +0900 Subject: [PATCH 2/9] nit --- packages/plugin-rsc/src/transforms/wrap-export.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index f48a645de..80ac1ae1b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -85,11 +85,12 @@ export function transformWrapExport( * * // output * async function action() {} - * action = __WRAP__(action, 'action') // maps to the `export` token - * export { action } + * action = __WRAP__(action, 'action') << maps to the `export` token + * export { action } << * ``` */ function rewriteDirectExports( + // start/end represents original `export` token range start: number, end: number, exports: ModuleExportEntry[], @@ -120,6 +121,7 @@ export function transformWrapExport( * ```js * // existing source binding remains unchanged * const local = init() + * export { local as renamed } // caller removes the original export * * // appended code * const $$wrap_local = __WRAP__(local, 'renamed') @@ -132,7 +134,6 @@ export function transformWrapExport( meta: ModuleExportMeta = {}, ) { exportNames.push(exportName) - appendedCode.push( `const $$wrap_${name} = /* #__PURE__ */ ${options.runtime( name, From 3828f588f1fa12d89f2abd9ee2d06c7076f7ebdd Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:30:42 +0900 Subject: [PATCH 3/9] refactor(rsc): clarify wrapped assignment helper Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/wrap-export.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 80ac1ae1b..50332af86 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -89,7 +89,7 @@ export function transformWrapExport( * export { action } << * ``` */ - function rewriteDirectExports( + function emitWrappedAssignments( // start/end represents original `export` token range start: number, end: number, @@ -153,7 +153,7 @@ export function transformWrapExport( const [entry] = group.exports if (!filter(entry.exportName, entry.meta)) continue validateNonAsyncFunction(options, group.declaration) - rewriteDirectExports( + emitWrappedAssignments( group.node.start, group.declaration.start, group.exports, @@ -198,7 +198,7 @@ export function transformWrapExport( } } } - rewriteDirectExports( + emitWrappedAssignments( group.node.start, group.declaration.start, exports, From 78ee6512379b40f603652f817b3979f5a2fd76f5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:35 +0900 Subject: [PATCH 4/9] nit --- .../src/transforms/module-export-scan.ts | 3 +++ .../plugin-rsc/src/transforms/wrap-export.ts | 18 +++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/module-export-scan.ts b/packages/plugin-rsc/src/transforms/module-export-scan.ts index ce7b9dbea..c8873625c 100644 --- a/packages/plugin-rsc/src/transforms/module-export-scan.ts +++ b/packages/plugin-rsc/src/transforms/module-export-scan.ts @@ -147,6 +147,9 @@ 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 exports: extractNames(declarator.id).map((name) => ({ localName: name, exportName: name, diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 50332af86..0d12fd2bf 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -146,10 +146,13 @@ export function transformWrapExport( for (const group of scanModuleExports(viteAst)) { if (group.type === 'declaration') { - // export async function action() {} - // ^^^^^^ - // becomes a local declaration followed by a wrapper assignment and - // re-export at module end. A filtered declaration remains untouched. + // // input: + // export function f() {} + // + // // output: + // function f() {} << strip export + // f = __WRAP__(f, 'f') << emit wrapper + // export { $$module_0_binding_f as f } << emit export const [entry] = group.exports if (!filter(entry.exportName, entry.meta)) continue validateNonAsyncFunction(options, group.declaration) @@ -160,10 +163,11 @@ export function transformWrapExport( new Set([entry.exportName]), ) } else if (group.type === 'variable-declaration') { + // // input: // export const selected = init(), skipped = init() - // ^^^^^^^^^^^^ - // becomes: - // let selected = init(), skipped = init() + // + // // output: + // let selected = init(), skipped = init() << strip export // selected = __WRAP__(selected, 'selected') // export { selected } // export { skipped } From 9119279f48ddd2c6f6e4a6697eaddc77d64eba87 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:45:01 +0900 Subject: [PATCH 5/9] nit --- packages/plugin-rsc/src/transforms/wrap-export.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 0d12fd2bf..4253c1f8e 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -171,9 +171,6 @@ export function transformWrapExport( // selected = __WRAP__(selected, 'selected') // export { selected } // export { skipped } - // - // `const` must become `let` because selected bindings are reassigned. If - // the entire declaration is filtered out, it remains unchanged. const exports = group.declarators.flatMap((item) => item.exports) const selectedExportNames = new Set( exports @@ -182,6 +179,7 @@ export function transformWrapExport( ) if (selectedExportNames.size === 0) continue + // change `const` to `let` to reassign local name if (group.declaration.kind === 'const') { output.update( group.declaration.start, From f8629e3ce0bec0066c7b49f3d6bfbd1ab7d3204f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:55:33 +0900 Subject: [PATCH 6/9] nit --- .../plugin-rsc/src/transforms/wrap-export.ts | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 4253c1f8e..bd6e3d44e 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -146,13 +146,11 @@ export function transformWrapExport( for (const group of scanModuleExports(viteAst)) { if (group.type === 'declaration') { - // // input: // export function f() {} - // - // // output: + // ⬇️ // function f() {} << strip export - // f = __WRAP__(f, 'f') << emit wrapper - // export { $$module_0_binding_f as f } << emit export + // f = __WRAP__(f, 'f') << emit + // export { $$module_0_binding_f as f } << emit const [entry] = group.exports if (!filter(entry.exportName, entry.meta)) continue validateNonAsyncFunction(options, group.declaration) @@ -163,14 +161,11 @@ export function transformWrapExport( new Set([entry.exportName]), ) } else if (group.type === 'variable-declaration') { - // // input: // export const selected = init(), skipped = init() - // - // // output: + // ⬇️ // let selected = init(), skipped = init() << strip export - // selected = __WRAP__(selected, 'selected') - // export { selected } - // export { skipped } + // selected = __WRAP__(selected, 'selected') << emit + // export { selected, skipped } << emit const exports = group.declarators.flatMap((item) => item.exports) const selectedExportNames = new Set( exports @@ -207,10 +202,10 @@ export function transformWrapExport( selectedExportNames, ) } else if (group.type === 'specifiers') { - // export { selected as action, skipped } - // becomes an appended wrapper export for `action` plus a preserved export - // for `skipped`. Filtered specifiers are not routed through generated - // bindings, and a fully filtered statement remains untouched. + // export { selected as renamed, skipped } + // ⬇️ + // const $$wrap_selected = __WRAP__(selected, 'renamed') + // export { $$wrap_selected as renamed, skipped } for (const entry of group.exports) { tinyassert(entry.node.local.type === 'Identifier') if (entry.node.exported.type !== 'Identifier') { @@ -225,6 +220,7 @@ export function transformWrapExport( ) if (selectedExports.length === 0) continue + // remove entire original export statement output.remove(group.node.start, group.node.end) const skippedExports: string[] = [] for (const entry of group.exports) { @@ -237,10 +233,12 @@ export function transformWrapExport( continue } if (group.node.source) { + // introduce local variable via renamed import // export { remote as action } from './dep' - // becomes an import binding followed by its wrapped export. Importing - // gives the runtime callback a local implementation expression. + // ⬇️ + // import { remote as $$import_remote } from './dep' appendedCode.push( + // TODO: Preserve import attributes from the original re-export. `import { ${entry.localName} as $$import_${entry.localName} } from ${group.node.source.raw}`, ) emitWrappedBinding( @@ -272,16 +270,13 @@ export function transformWrapExport( const localName = group.localName ?? '$$default' if (group.kind === 'named-declaration') { // export default function Page() {} - // ^^^^^^^^^^^^^^^ - // becomes a named local declaration followed by a separate wrapper - // binding and default export. Keeping the declaration preserves its - // module-local name and scope. + // ⬇️ + // function Page() {} output.remove(group.node.start, group.node.declaration.start) } else { // export default expression - // ^^^^^^^^^^^^^^^ - // becomes `const $$default = expression`, which gives anonymous and - // arbitrary default values a local implementation binding to wrap. + // ⬇️ + // const $$default = expression output.update( group.node.start, group.node.declaration.start, From 39ef7031a33443a2e9677236c7eaf3b3f6f1bd72 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:56:28 +0900 Subject: [PATCH 7/9] refactor(rsc): combine direct export specifiers Co-authored-by: OpenCode --- .../dependent-declarators.js.map.snap.md | 5 ++-- .../dependent-declarators.js.snap.md | 5 ++-- .../src/transforms/wrap-export.test.ts | 6 ++--- .../plugin-rsc/src/transforms/wrap-export.ts | 27 ++++++++++--------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md index eef48cd07..09bc16669 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.map.snap.md @@ -6,9 +6,8 @@ (2:12) " first = async () => 'first action called',\n" --> (2:3) " first = async () => 'first action called',\n" (3:0) " second = first\n" --> (3:0) " second = first\n" (2:0) "export const first = async () => 'first action called',\n" --> (4:0) "first = /* #__PURE__ */ registerServerReference(first, \"first\");\n" -(2:0) "export const first = async () => 'first action called',\n" --> (5:0) "export { first };\n" -(2:0) "export const first = async () => 'first action called',\n" --> (6:0) "second = /* #__PURE__ */ registerServerReference(second, \"second\");\n" -(2:0) "export const first = async () => 'first action called',\n" --> (7:0) "export { second };\n" +(2:0) "export const first = async () => 'first action called',\n" --> (5:0) "second = /* #__PURE__ */ registerServerReference(second, \"second\");\n" +(2:0) "export const first = async () => 'first action called',\n" --> (6:0) "export { first, second };\n" ``` ## module-export-effect diff --git a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md index e7a106b22..2a66ed25c 100644 --- a/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md +++ b/packages/plugin-rsc/src/transforms/fixtures/source-map/wrap-export/dependent-declarators.js.snap.md @@ -13,7 +13,7 @@ export const first = async () => 'first action called', **References:** first, second -[Source map visualization](https://evanw.github.io/source-map-visualization/#MjQ4ACd1c2Ugc2VydmVyJwoKbGV0IGZpcnN0ID0gYXN5bmMgKCkgPT4gJ2ZpcnN0IGFjdGlvbiBjYWxsZWQnLAogIHNlY29uZCA9IGZpcnN0CmZpcnN0ID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKGZpcnN0LCAiZmlyc3QiKTsKZXhwb3J0IHsgZmlyc3QgfTsKc2Vjb25kID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKHNlY29uZCwgInNlY29uZCIpOwpleHBvcnQgeyBzZWNvbmQgfTsKMzU4AHsidmVyc2lvbiI6Mywic291cmNlcyI6WyIiXSwic291cmNlc0NvbnRlbnQiOlsiJ3VzZSBzZXJ2ZXInXG5cbmV4cG9ydCBjb25zdCBmaXJzdCA9IGFzeW5jICgpID0+ICdmaXJzdCBhY3Rpb24gY2FsbGVkJyxcbiAgc2Vjb25kID0gZmlyc3RcbiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxDQUFDLEdBQUcsQ0FBQyxNQUFNOztBQUVKLEdBQUssQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDO0FBQ3RELENBQUMsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDO0FBRFg7QUFBQTtBQUFBO0FBQUE7In0=) +[Source map visualization](https://evanw.github.io/source-map-visualization/#MjM3ACd1c2Ugc2VydmVyJwoKbGV0IGZpcnN0ID0gYXN5bmMgKCkgPT4gJ2ZpcnN0IGFjdGlvbiBjYWxsZWQnLAogIHNlY29uZCA9IGZpcnN0CmZpcnN0ID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKGZpcnN0LCAiZmlyc3QiKTsKc2Vjb25kID0gLyogI19fUFVSRV9fICovIHJlZ2lzdGVyU2VydmVyUmVmZXJlbmNlKHNlY29uZCwgInNlY29uZCIpOwpleHBvcnQgeyBmaXJzdCwgc2Vjb25kIH07CjM1MwB7InZlcnNpb24iOjMsInNvdXJjZXMiOlsiIl0sInNvdXJjZXNDb250ZW50IjpbIid1c2Ugc2VydmVyJ1xuXG5leHBvcnQgY29uc3QgZmlyc3QgPSBhc3luYyAoKSA9PiAnZmlyc3QgYWN0aW9uIGNhbGxlZCcsXG4gIHNlY29uZCA9IGZpcnN0XG4iXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsQ0FBQyxHQUFHLENBQUMsTUFBTTs7QUFFSixHQUFLLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsTUFBTSxDQUFDLE1BQU0sQ0FBQztBQUN0RCxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQztBQURYO0FBQUE7QUFBQTsifQ==) ```js 'use server' @@ -21,9 +21,8 @@ export const first = async () => 'first action called', let first = async () => 'first action called', second = first first = /* #__PURE__ */ registerServerReference(first, "first"); -export { first }; second = /* #__PURE__ */ registerServerReference(second, "second"); -export { second }; +export { first, second }; ``` ## module-export-effect diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index d3e19e413..58d01d77c 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -99,9 +99,8 @@ export const { x, y: [z] } = { x: 0, y: [1] }; " let { x, y: [z] } = { x: 0, y: [1] }; x = /* #__PURE__ */ $$wrap(x, "", "x"); - export { x }; z = /* #__PURE__ */ $$wrap(z, "", "z"); - export { z }; + export { x, z }; " `) }) @@ -235,8 +234,7 @@ export { a as aa }; a = /* #__PURE__ */ $$wrap(a, "", "a"); export { a }; b = /* #__PURE__ */ $$wrap(b, "", "b"); - export { b }; - export { b_no }; + export { b, b_no }; ; import { c as $$import_c } from "./c"; const $$wrap_$$import_c = /* #__PURE__ */ $$wrap($$import_c, "", "c"); diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index bd6e3d44e..dcab91c78 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -97,19 +97,20 @@ export function transformWrapExport( selectedExportNames: Set, ) { exportNames.push(...selectedExportNames) - const newCode = exports - .map((e) => [ - selectedExportNames.has(e.exportName) && - `${e.localName} = /* #__PURE__ */ ${options.runtime( - e.localName, - e.exportName, - e.meta, - )};\n`, - `export { ${e.localName} };\n`, - ]) - .flat() - .filter(Boolean) - .join('') + const newCode = + exports + .map( + (e) => + selectedExportNames.has(e.exportName) && + `${e.localName} = /* #__PURE__ */ ${options.runtime( + e.localName, + e.exportName, + e.meta, + )};\n`, + ) + .filter(Boolean) + .join('') + + `export { ${exports.map((e) => e.localName).join(', ')} };\n` output.update(start, end, newCode) output.move(start, end, input.length) } From b79135e1587229b79b60ccc2e38c28f04fe351b6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:59:33 +0900 Subject: [PATCH 8/9] refactor(rsc): unify wrapped specifier emission Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/wrap-export.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index dcab91c78..072c3124f 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -233,23 +233,20 @@ export function transformWrapExport( ) continue } - if (group.node.source) { + let binding = entry.localName + const source = group.node.source + if (source) { // introduce local variable via renamed import // export { remote as action } from './dep' // ⬇️ // import { remote as $$import_remote } from './dep' + binding = `$$import_${entry.localName}` appendedCode.push( // TODO: Preserve import attributes from the original re-export. - `import { ${entry.localName} as $$import_${entry.localName} } from ${group.node.source.raw}`, + `import { ${entry.localName} as ${binding} } from ${source.raw}`, ) - emitWrappedBinding( - `$$import_${entry.localName}`, - entry.exportName, - entry.meta, - ) - } else { - emitWrappedBinding(entry.localName, entry.exportName, entry.meta) } + emitWrappedBinding(binding, entry.exportName, entry.meta) } if (skippedExports.length > 0) { const source = group.node.source ? ` from ${group.node.source.raw}` : '' From a0b8ab37eeb35d9810ddd503874085f85943e1ea Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:03:16 +0900 Subject: [PATCH 9/9] refactor(rsc): defer filtered specifier rewrites Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/wrap-export.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 072c3124f..0c6361a40 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -207,6 +207,8 @@ export function transformWrapExport( // ⬇️ // const $$wrap_selected = __WRAP__(selected, 'renamed') // export { $$wrap_selected as renamed, skipped } + const skippedExports: string[] = [] + let selected = false for (const entry of group.exports) { tinyassert(entry.node.local.type === 'Identifier') if (entry.node.exported.type !== 'Identifier') { @@ -215,17 +217,7 @@ export function transformWrapExport( { pos: entry.node.exported.start }, ) } - } - const selectedExports = group.exports.filter((entry) => - filter(entry.exportName, entry.meta), - ) - if (selectedExports.length === 0) continue - - // remove entire original export statement - output.remove(group.node.start, group.node.end) - const skippedExports: string[] = [] - for (const entry of group.exports) { - if (!selectedExports.includes(entry)) { + if (!filter(entry.exportName, entry.meta)) { skippedExports.push( entry.localName === entry.exportName ? entry.localName @@ -233,6 +225,8 @@ export function transformWrapExport( ) continue } + selected = true + let binding = entry.localName const source = group.node.source if (source) { @@ -248,9 +242,14 @@ export function transformWrapExport( } emitWrappedBinding(binding, entry.exportName, entry.meta) } - if (skippedExports.length > 0) { - const source = group.node.source ? ` from ${group.node.source.raw}` : '' - appendedCode.push(`export { ${skippedExports.join(', ')} }${source}`) + if (selected) { + output.remove(group.node.start, group.node.end) + if (skippedExports.length > 0) { + const source = group.node.source + ? ` from ${group.node.source.raw}` + : '' + appendedCode.push(`export { ${skippedExports.join(', ')} }${source}`) + } } } else if (group.type === 'export-all') { // Vue SFC uses ExportAllDeclaration to re-export its setup script, so