From c8724223f3b047c54e212c8a1ab69b601eb22739 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 19 Jun 2026 00:28:39 +0100 Subject: [PATCH 1/9] fix(rsc): exclude filtered exports from transforms --- .../src/transforms/wrap-export.test.ts | 42 ++++++++++ .../plugin-rsc/src/transforms/wrap-export.ts | 84 ++++++++++++++----- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index c3cb7473a..8750afc34 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -314,4 +314,46 @@ export default Page; " `) }) + + test('filtered exports are not validated or reported', async () => { + const input = ` +export const revalidate = 1; +export default async function Page() {} +` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + filter: (name) => name !== 'revalidate', + }) + expect(result.exportNames).toEqual(['default']) + expect(result.output.toString()).toContain('export { revalidate };') + }) + + test('filtered default exports are not validated or reported', async () => { + const input = `export default 1;` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + filter: () => false, + }) + expect(result.exportNames).toEqual([]) + expect(result.output.toString()).toContain( + 'export { $$default as default }', + ) + }) + + test('unknown identifier exports remain eligible for wrapping', async () => { + const input = ` +const cached = async () => 1; +export default cached; +` + const ast = await parseAstAsync(input) + const result = transformWrapExport(input, ast, { + runtime: (value, name) => `$$wrap(${value}, ${JSON.stringify(name)})`, + filter: (_name, meta) => meta.isFunction !== false, + }) + expect(result.exportNames).toEqual(['default']) + }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index f5328e967..b4f303f24 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -39,7 +39,15 @@ export function transformWrapExport( end: number, exports: { name: string; meta: ExportMeta }[], ) { - exportNames.push(...exports.map((e) => e.name)) + const filteredExports = exports.map((item) => ({ + ...item, + shouldWrap: filter(item.name, item.meta), + })) + exportNames.push( + ...filteredExports + .filter((item) => item.shouldWrap) + .map((item) => item.name), + ) // update code and move to preserve `registerServerReference` position // e.g. // input @@ -49,9 +57,9 @@ export function transformWrapExport( // async function f() {} // f = registerServerReference(f, ...) << maps to original "export" token // export { f } << - const newCode = exports + const newCode = filteredExports .map((e) => [ - filter(e.name, e.meta) && + e.shouldWrap && `${e.name} = /* #__PURE__ */ ${options.runtime( e.name, e.name, @@ -67,11 +75,11 @@ export function transformWrapExport( } function wrapExport(name: string, exportName: string, meta: ExportMeta = {}) { - exportNames.push(exportName) if (!filter(exportName, meta)) { toAppend.push(`export { ${name} as ${exportName} }`) return } + exportNames.push(exportName) toAppend.push( `const $$wrap_${name} = /* #__PURE__ */ ${options.runtime( @@ -94,20 +102,16 @@ export function transformWrapExport( /** * export function foo() {} */ - validateNonAsyncFunction(options, node.declaration) const name = node.declaration.id.name - wrapSimple(node.start, node.declaration.start, [ - { name, meta: { isFunction: true, declName: name } }, - ]) + const meta = { isFunction: true, declName: name } + if (filter(name, meta)) { + validateNonAsyncFunction(options, node.declaration) + } + wrapSimple(node.start, node.declaration.start, [{ name, meta }]) } else if (node.declaration.type === 'VariableDeclaration') { /** * export const foo = 1, bar = 2 */ - for (const decl of node.declaration.declarations) { - if (decl.init) { - validateNonAsyncFunction(options, decl.init) - } - } if (node.declaration.kind === 'const') { output.update( node.declaration.start, @@ -119,13 +123,34 @@ export function transformWrapExport( extractNames(decl.id), ) // treat only simple single decl as function - let isFunction = false + let isFunction: boolean | undefined if (node.declaration.declarations.length === 1) { const decl = node.declaration.declarations[0]! - isFunction = - decl.id.type === 'Identifier' && - (decl.init?.type === 'ArrowFunctionExpression' || - decl.init?.type === 'FunctionExpression') + if (decl.id.type === 'Identifier') { + if ( + decl.init?.type === 'ArrowFunctionExpression' || + decl.init?.type === 'FunctionExpression' + ) { + isFunction = true + } else if ( + decl.init?.type === 'Literal' || + decl.init?.type === 'ObjectExpression' || + decl.init?.type === 'ArrayExpression' || + decl.init?.type === 'ClassExpression' + ) { + isFunction = false + } + } + } + for (const decl of node.declaration.declarations) { + if ( + decl.init && + extractNames(decl.id).some((name) => + filter(name, { isFunction, declName: name }), + ) + ) { + validateNonAsyncFunction(options, decl.init) + } } wrapSimple( node.start, @@ -198,9 +223,8 @@ export function transformWrapExport( * export default () => {} */ if (node.type === 'ExportDefaultDeclaration') { - validateNonAsyncFunction(options, node.declaration) let localName: string - let isFunction = false + let isFunction: boolean | undefined let declName: string | undefined let defaultExportIdentifierName: string | undefined if ( @@ -219,13 +243,29 @@ export function transformWrapExport( output.update(node.start, node.declaration.start, 'const $$default = ') if (node.declaration.type === 'Identifier') { defaultExportIdentifierName = node.declaration.name + } else if ( + node.declaration.type === 'ArrowFunctionExpression' || + node.declaration.type === 'FunctionExpression' + ) { + isFunction = true + } else if ( + node.declaration.type === 'Literal' || + node.declaration.type === 'ObjectExpression' || + node.declaration.type === 'ArrayExpression' || + node.declaration.type === 'ClassExpression' + ) { + isFunction = false } } - wrapExport(localName, 'default', { + const defaultMeta = { isFunction, declName, defaultExportIdentifierName, - }) + } + if (filter('default', defaultMeta)) { + validateNonAsyncFunction(options, node.declaration) + } + wrapExport(localName, 'default', defaultMeta) } } From 834681b357703e8ad6b4ad8b0bfcc684257320d1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:40:41 +0900 Subject: [PATCH 2/9] test(rsc): snapshot filtered export output Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 28 ++++++++++++++++--- .../plugin-rsc/src/transforms/wrap-export.ts | 14 ++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 8750afc34..890769a65 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -327,7 +327,16 @@ export default async function Page() {} filter: (name) => name !== 'revalidate', }) expect(result.exportNames).toEqual(['default']) - expect(result.output.toString()).toContain('export { revalidate };') + expect(result.output.toString()).toMatchInlineSnapshot(` + " + let revalidate = 1; + async function Page() {} + export { revalidate }; + ; + const $$wrap_Page = /* #__PURE__ */ $$wrap(Page, "default"); + export { $$wrap_Page as default }; + " + `) }) test('filtered default exports are not validated or reported', async () => { @@ -339,9 +348,11 @@ export default async function Page() {} filter: () => false, }) expect(result.exportNames).toEqual([]) - expect(result.output.toString()).toContain( - 'export { $$default as default }', - ) + expect(result.output.toString()).toMatchInlineSnapshot(` + "const $$default = 1;; + export { $$default as default }; + " + `) }) test('unknown identifier exports remain eligible for wrapping', async () => { @@ -355,5 +366,14 @@ export default cached; filter: (_name, meta) => meta.isFunction !== false, }) expect(result.exportNames).toEqual(['default']) + expect(result.output.toString()).toMatchInlineSnapshot(` + " + const cached = async () => 1; + const $$default = cached; + ; + const $$wrap_$$default = /* #__PURE__ */ $$wrap($$default, "default"); + export { $$wrap_$$default as default }; + " + `) }) }) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index b4f303f24..c1ca43a2b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -4,8 +4,22 @@ import MagicString from 'magic-string' import { extractNames, validateNonAsyncFunction } from './utils' type ExportMeta = { + /** + * The local declaration name when statically available. + * For example, `Page` in `export function Page() {}`. + */ declName?: string + /** + * Whether the export is statically known to be a function + * (`export const Page = () => {}`) or non-function + * (`export const value = 1`). Undefined means unknown, for example + * `export default value`. + */ isFunction?: boolean + /** + * The local identifier used by a default export. + * For example, `Page` in `export default Page`. + */ defaultExportIdentifierName?: string } From 6f77f7dc0e430b2205df16b36fb0d85a34184340 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:46:39 +0900 Subject: [PATCH 3/9] refactor(rsc): extract export function classification Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/wrap-export.ts | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index c1ca43a2b..5320ca762 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -1,5 +1,5 @@ import { tinyassert } from '@hiogawa/utils' -import type { Program } from 'estree' +import type { ExportDefaultDeclaration, Node, Program } from 'estree' import MagicString from 'magic-string' import { extractNames, validateNonAsyncFunction } from './utils' @@ -140,20 +140,8 @@ export function transformWrapExport( let isFunction: boolean | undefined if (node.declaration.declarations.length === 1) { const decl = node.declaration.declarations[0]! - if (decl.id.type === 'Identifier') { - if ( - decl.init?.type === 'ArrowFunctionExpression' || - decl.init?.type === 'FunctionExpression' - ) { - isFunction = true - } else if ( - decl.init?.type === 'Literal' || - decl.init?.type === 'ObjectExpression' || - decl.init?.type === 'ArrayExpression' || - decl.init?.type === 'ClassExpression' - ) { - isFunction = false - } + if (decl.id.type === 'Identifier' && decl.init) { + isFunction = getIsFunction(decl.init) } } for (const decl of node.declaration.declarations) { @@ -257,18 +245,8 @@ export function transformWrapExport( output.update(node.start, node.declaration.start, 'const $$default = ') if (node.declaration.type === 'Identifier') { defaultExportIdentifierName = node.declaration.name - } else if ( - node.declaration.type === 'ArrowFunctionExpression' || - node.declaration.type === 'FunctionExpression' - ) { - isFunction = true - } else if ( - node.declaration.type === 'Literal' || - node.declaration.type === 'ObjectExpression' || - node.declaration.type === 'ArrayExpression' || - node.declaration.type === 'ClassExpression' - ) { - isFunction = false + } else { + isFunction = getIsFunction(node.declaration) } } const defaultMeta = { @@ -289,3 +267,22 @@ export function transformWrapExport( return { exportNames, output } } + +function getIsFunction( + node: Node | ExportDefaultDeclaration['declaration'], +): boolean | undefined { + if ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' + ) { + return true + } + if ( + node.type === 'Literal' || + node.type === 'ObjectExpression' || + node.type === 'ArrayExpression' || + node.type === 'ClassExpression' + ) { + return false + } +} From 09afbbd9c67c0fdd6e5691a8a33adc90d913418b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:53:27 +0900 Subject: [PATCH 4/9] refactor(rsc): type export metadata objects Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/wrap-export.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 5320ca762..a375fd150 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -117,7 +117,7 @@ export function transformWrapExport( * export function foo() {} */ const name = node.declaration.id.name - const meta = { isFunction: true, declName: name } + const meta: ExportMeta = { isFunction: true, declName: name } if (filter(name, meta)) { validateNonAsyncFunction(options, node.declaration) } @@ -249,7 +249,7 @@ export function transformWrapExport( isFunction = getIsFunction(node.declaration) } } - const defaultMeta = { + const defaultMeta: ExportMeta = { isFunction, declName, defaultExportIdentifierName, From dfe9e21298fde634c018487ace3e690c957432ca Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:05:38 +0900 Subject: [PATCH 5/9] test(rsc): cover export metadata Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 890769a65..8c254064b 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -376,4 +376,109 @@ export default cached; " `) }) + + test('runtime export meta', async () => { + const examples: [input: string, expected: unknown[]][] = [ + [`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]], + [`export class Cls {}`, [{ isFunction: true, declName: 'Cls' }]], + [ + `export const Arrow = () => {}`, + [{ isFunction: true, declName: 'Arrow' }], + ], + [ + `export const FnExpression = function () {}`, + [{ isFunction: true, declName: 'FnExpression' }], + ], + [ + `export const Literal = 1`, + [{ isFunction: false, declName: 'Literal' }], + ], + [ + `export const ObjectValue = {}`, + [{ isFunction: false, declName: 'ObjectValue' }], + ], + [ + `export const ArrayValue = []`, + [{ isFunction: false, declName: 'ArrayValue' }], + ], + [ + `export const ClassValue = class {}`, + [{ isFunction: false, declName: 'ClassValue' }], + ], + [ + `export const Unknown = getValue()`, + [{ isFunction: undefined, declName: 'Unknown' }], + ], + [ + `export const MultiFn = () => {}, MultiValue = 1`, + // TODO: Classify each declarator independently. + [ + { isFunction: undefined, declName: 'MultiFn' }, + { isFunction: undefined, declName: 'MultiValue' }, + ], + ], + [ + `export default function Page() {}`, + [ + { + isFunction: true, + declName: 'Page', + defaultExportIdentifierName: undefined, + }, + ], + ], + [ + `export default class Page {}`, + [ + { + isFunction: false, + declName: 'Page', + defaultExportIdentifierName: undefined, + }, + ], + ], + [ + `export default () => {}`, + [ + { + isFunction: true, + declName: undefined, + defaultExportIdentifierName: undefined, + }, + ], + ], + [ + `export default 1`, + [ + { + isFunction: false, + declName: undefined, + defaultExportIdentifierName: undefined, + }, + ], + ], + [ + `const Page = () => {}; export default Page`, + [ + { + isFunction: undefined, + declName: undefined, + defaultExportIdentifierName: 'Page', + }, + ], + ], + ] + + for (const [input, expected] of examples) { + const actual: unknown[] = [] + const ast = await parseAstAsync(input) + transformWrapExport(input, ast, { + runtime(value, _name, meta) { + actual.push(meta) + return value + }, + }) + expect(actual).toEqual(expected) + } + }) }) From 37b50995e9d12e7f3232f9963030d45e1d041370 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:12:29 +0900 Subject: [PATCH 6/9] test(rsc): expand export metadata cases Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 8c254064b..1ed0961fa 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -380,6 +380,7 @@ export default cached; test('runtime export meta', async () => { const examples: [input: string, expected: unknown[]][] = [ [`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]], + // TODO: Align class declaration metadata with class expressions and default exports. [`export class Cls {}`, [{ isFunction: true, declName: 'Cls' }]], [ `export const Arrow = () => {}`, @@ -405,17 +406,12 @@ export default cached; `export const ClassValue = class {}`, [{ isFunction: false, declName: 'ClassValue' }], ], - [ - `export const Unknown = getValue()`, - [{ isFunction: undefined, declName: 'Unknown' }], - ], + [`export const Unknown = getValue()`, [{ declName: 'Unknown' }]], + [`export const { id } = getValue()`, [{ declName: 'id' }]], [ `export const MultiFn = () => {}, MultiValue = 1`, // TODO: Classify each declarator independently. - [ - { isFunction: undefined, declName: 'MultiFn' }, - { isFunction: undefined, declName: 'MultiValue' }, - ], + [{ declName: 'MultiFn' }, { declName: 'MultiValue' }], ], [ `export default function Page() {}`, @@ -423,27 +419,25 @@ export default cached; { isFunction: true, declName: 'Page', - defaultExportIdentifierName: undefined, }, ], ], + [`export default function () {}`, [{}]], [ `export default class Page {}`, [ { isFunction: false, declName: 'Page', - defaultExportIdentifierName: undefined, }, ], ], + [`export default class {}`, [{}]], [ `export default () => {}`, [ { isFunction: true, - declName: undefined, - defaultExportIdentifierName: undefined, }, ], ], @@ -452,8 +446,6 @@ export default cached; [ { isFunction: false, - declName: undefined, - defaultExportIdentifierName: undefined, }, ], ], @@ -461,12 +453,12 @@ export default cached; `const Page = () => {}; export default Page`, [ { - isFunction: undefined, - declName: undefined, defaultExportIdentifierName: 'Page', }, ], ], + [`const id = async () => {}; export { id }`, [{}]], + [`export { id } from './dep'`, [{}]], ] for (const [input, expected] of examples) { From 7089f33a6d855270958776d13668477e31eb813e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:14:47 +0900 Subject: [PATCH 7/9] docs(rsc): document export metadata states Co-authored-by: OpenCode --- .../plugin-rsc/src/transforms/wrap-export.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index a375fd150..7e5721a94 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -6,19 +6,30 @@ import { extractNames, validateNonAsyncFunction } from './utils' type ExportMeta = { /** * The local declaration name when statically available. - * For example, `Page` in `export function Page() {}`. + * + * - `"Page"` for `export function Page() {}` + * - `"Page"` for `export const Page = () => {}` + * - `undefined` for `export default () => {}` + * - `undefined` for `export { Page }` */ declName?: string /** - * Whether the export is statically known to be a function - * (`export const Page = () => {}`) or non-function - * (`export const value = 1`). Undefined means unknown, for example - * `export default value`. + * 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 used by a default export. - * For example, `Page` in `export default Page`. + * The local identifier referenced by a default export. + * The RSC CSS transform uses this to detect a component by its capitalized + * local name and preserve that name on the generated wrapper. + * + * - `"Page"` for `const Page = () => {}; export default Page` + * - `undefined` for `export default function Page() {}` + * - `undefined` for `export default () => {}` */ defaultExportIdentifierName?: string } From cb9e9edda9763ce7f5bdcbef7533d83a4ae49881 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:23:26 +0900 Subject: [PATCH 8/9] fix(rsc): align class export metadata Co-authored-by: OpenCode --- packages/plugin-rsc/src/transforms/wrap-export.test.ts | 7 +++---- packages/plugin-rsc/src/transforms/wrap-export.ts | 9 +++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 1ed0961fa..307b1ff6e 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -380,8 +380,7 @@ export default cached; test('runtime export meta', async () => { const examples: [input: string, expected: unknown[]][] = [ [`export function Fn() {}`, [{ isFunction: true, declName: 'Fn' }]], - // TODO: Align class declaration metadata with class expressions and default exports. - [`export class Cls {}`, [{ isFunction: true, declName: 'Cls' }]], + [`export class Cls {}`, [{ isFunction: false, declName: 'Cls' }]], [ `export const Arrow = () => {}`, [{ isFunction: true, declName: 'Arrow' }], @@ -422,7 +421,7 @@ export default cached; }, ], ], - [`export default function () {}`, [{}]], + [`export default function () {}`, [{ isFunction: true }]], [ `export default class Page {}`, [ @@ -432,7 +431,7 @@ export default cached; }, ], ], - [`export default class {}`, [{}]], + [`export default class {}`, [{ isFunction: false }]], [ `export default () => {}`, [ diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 7e5721a94..35dd3f98a 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -128,7 +128,10 @@ export function transformWrapExport( * export function foo() {} */ const name = node.declaration.id.name - const meta: ExportMeta = { isFunction: true, declName: name } + const meta: ExportMeta = { + isFunction: getIsFunction(node.declaration), + declName: name, + } if (filter(name, meta)) { validateNonAsyncFunction(options, node.declaration) } @@ -248,7 +251,7 @@ export function transformWrapExport( // preserve name scope for `function foo() {}` and `class Foo {}` localName = node.declaration.id.name output.remove(node.start, node.declaration.start) - isFunction = node.declaration.type === 'FunctionDeclaration' + isFunction = getIsFunction(node.declaration) declName = node.declaration.id.name } else { // otherwise we can introduce new variable @@ -283,12 +286,14 @@ 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' || From e395ee9d53078d4c403b9c668864409261aae354 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:43:21 +0900 Subject: [PATCH 9/9] fix(rsc): classify each export declarator Co-authored-by: OpenCode --- .../src/transforms/wrap-export.test.ts | 10 +++-- .../plugin-rsc/src/transforms/wrap-export.ts | 44 +++++++------------ 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 307b1ff6e..1e63d1e59 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -407,10 +407,14 @@ export default cached; ], [`export const Unknown = getValue()`, [{ declName: 'Unknown' }]], [`export const { id } = getValue()`, [{ declName: 'id' }]], + [`export const [a, b] = []`, [{ declName: 'a' }, { declName: 'b' }]], [ - `export const MultiFn = () => {}, MultiValue = 1`, - // TODO: Classify each declarator independently. - [{ declName: 'MultiFn' }, { declName: 'MultiValue' }], + `export const MultiFn = () => {}, MultiValue = 1, MultiUnknown = getValue()`, + [ + { isFunction: true, declName: 'MultiFn' }, + { isFunction: false, declName: 'MultiValue' }, + { declName: 'MultiUnknown' }, + ], ], [ `export default function Page() {}`, diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 35dd3f98a..4e7bd6c86 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -34,6 +34,8 @@ type ExportMeta = { defaultExportIdentifierName?: string } +type ExportWithMeta = { name: string; meta: ExportMeta } + export type TransformWrapExportFilter = ( name: string, meta: ExportMeta, @@ -59,11 +61,7 @@ export function transformWrapExport( const toAppend: string[] = [] const filter = options.filter ?? (() => true) - function wrapSimple( - start: number, - end: number, - exports: { name: string; meta: ExportMeta }[], - ) { + function wrapSimple(start: number, end: number, exports: ExportWithMeta[]) { const filteredExports = exports.map((item) => ({ ...item, shouldWrap: filter(item.name, item.meta), @@ -147,35 +145,27 @@ export function transformWrapExport( 'let', ) } - const names = node.declaration.declarations.flatMap((decl) => - extractNames(decl.id), - ) - // treat only simple single decl as function - let isFunction: boolean | undefined - if (node.declaration.declarations.length === 1) { - const decl = node.declaration.declarations[0]! - if (decl.id.type === 'Identifier' && decl.init) { - isFunction = getIsFunction(decl.init) - } - } + 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 && - extractNames(decl.id).some((name) => - filter(name, { isFunction, declName: name }), - ) + declarationExports.some(({ name, meta }) => filter(name, meta)) ) { validateNonAsyncFunction(options, decl.init) } } - wrapSimple( - node.start, - node.declaration.start, - names.map((name) => ({ - name, - meta: { isFunction, declName: name }, - })), - ) + wrapSimple(node.start, node.declaration.start, exports) } else { node.declaration satisfies never }