From 7cc932c31499948a323c777b0c051c8ae40abcb0 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 21 May 2026 22:23:26 +0100 Subject: [PATCH 1/3] fix(rsc): handle `export *` re-exports in `use client` modules The `use-client` proxy transform threw `unsupported ExportAllDeclaration` on any `"use client"` file containing `export * from '...'`. Pre-resolve bare `export *` declarations to explicit named re-exports before running the proxy transform (mirroring Next.js's webpack plugin behavior), and handle `export * as ns from` directly in the pure proxy/wrap transforms since the name is statically known. Closes cloudflare/vinext#1352 --- packages/plugin-rsc/e2e/basic.test.ts | 8 + .../basic/src/routes/export-all/index.tsx | 7 + .../basic/src/routes/export-all/named.tsx | 9 ++ .../basic/src/routes/export-all/server.tsx | 10 ++ .../examples/basic/src/routes/root.tsx | 2 + packages/plugin-rsc/src/plugin.ts | 149 +++++++++++++++++- .../src/transforms/proxy-export.test.ts | 53 ++++++- .../plugin-rsc/src/transforms/proxy-export.ts | 14 +- .../src/transforms/wrap-export.test.ts | 8 +- .../plugin-rsc/src/transforms/wrap-export.ts | 21 ++- 10 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 packages/plugin-rsc/examples/basic/src/routes/export-all/index.tsx create mode 100644 packages/plugin-rsc/examples/basic/src/routes/export-all/named.tsx create mode 100644 packages/plugin-rsc/examples/basic/src/routes/export-all/server.tsx diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 6c2346167..f5bb8aa62 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -1874,6 +1874,14 @@ function defineTest(f: Fixture) { ) }) + test('export *', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('test-export-all')).toHaveText( + 'test-export-all:export-all-a|export-all-b|export-all-named', + ) + }) + test('virtual module with use client', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) diff --git a/packages/plugin-rsc/examples/basic/src/routes/export-all/index.tsx b/packages/plugin-rsc/examples/basic/src/routes/export-all/index.tsx new file mode 100644 index 000000000..6d9eb1d8a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/export-all/index.tsx @@ -0,0 +1,7 @@ +'use client' + +export * from './named' + +export function ExportAllNamed() { + return export-all-named +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/export-all/named.tsx b/packages/plugin-rsc/examples/basic/src/routes/export-all/named.tsx new file mode 100644 index 000000000..81dd54806 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/export-all/named.tsx @@ -0,0 +1,9 @@ +'use client' + +export function ExportAllA() { + return export-all-a +} + +export function ExportAllB() { + return export-all-b +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/export-all/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/export-all/server.tsx new file mode 100644 index 000000000..436c2eab0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/export-all/server.tsx @@ -0,0 +1,10 @@ +import { ExportAllA, ExportAllB, ExportAllNamed } from '.' + +export function TestExportAll() { + return ( +
+ test-export-all: + || +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx index bcd7699fd..5192bca23 100644 --- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -29,6 +29,7 @@ import { TestClientInServer } from './deps/client-in-server/server' import { TestServerInClient } from './deps/server-in-client/client' import { TestServerInServer } from './deps/server-in-server/server' import { TestTransitiveCjsClient } from './deps/transitive-cjs/client' +import { TestExportAll } from './export-all/server' import { TestHmrClientDep } from './hmr-client-dep/client' import { TestHmrClientDep2 } from './hmr-client-dep2/client' import { TestHmrClientDep3 } from './hmr-client-dep3/server' @@ -125,6 +126,7 @@ export function Root(props: { url: URL }) { + diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 5cbe58502..d2789c642 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -23,6 +23,7 @@ import { isCSSRequest, normalizePath, parseAstAsync, + transformWithOxc, } from 'vite' import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' @@ -65,6 +66,7 @@ import { } from './plugins/vite-utils' import { type TransformWrapExportFilter, + extractNames, hasDirective, transformDirectiveProxyExport, transformServerActionServer, @@ -1375,6 +1377,126 @@ function globalAsyncLocalStoragePlugin(): Plugin[] { ] } +// Recursively collect the named exports of a module (following `export * from` +// chains), so that the RSC `use client`/`use server` proxy transforms can +// expand bare `export *` re-exports into explicit named re-exports before +// proxy generation. The pure proxy transform cannot do this on its own because +// the names live in another file. +async function collectExportNames( + ctx: Rollup.TransformPluginContext, + resolvedId: string, + seen: Set, +): Promise { + if (seen.has(resolvedId)) return [] + seen.add(resolvedId) + + // `this.load`'s ModuleInfo.code is build-only. In dev mode, the dev + // environment's `transformRequest` returns module-runner-specific output + // (e.g. `__vite_ssr_exportName__("X", ...)` instead of `export ... from`), + // which the AST walk below can't read. Use a plain oxc pass on the source + // instead so we get standard ESM exports to walk. + let moduleCode: string | undefined + try { + if (ctx.environment.mode === 'dev') { + const raw = await fs.promises.readFile(resolvedId, 'utf-8') + const result = await transformWithOxc(raw, resolvedId, { + sourcemap: false, + }) + moduleCode = result.code + } else { + const moduleInfo = await ctx.load({ id: resolvedId }) + moduleCode = moduleInfo.code ?? undefined + } + } catch { + return [] + } + if (!moduleCode) return [] + + let ast: Awaited> + try { + ast = await parseAstAsync(moduleCode) + } catch { + return [] + } + + const names: string[] = [] + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + if (node.declaration) { + if ( + node.declaration.type === 'FunctionDeclaration' || + node.declaration.type === 'ClassDeclaration' + ) { + if (node.declaration.id) names.push(node.declaration.id.name) + } else if (node.declaration.type === 'VariableDeclaration') { + for (const decl of node.declaration.declarations) { + names.push(...extractNames(decl.id)) + } + } + } else { + for (const spec of node.specifiers) { + if ( + spec.exported.type === 'Identifier' && + spec.exported.name !== 'default' + ) { + names.push(spec.exported.name) + } + } + } + } else if (node.type === 'ExportAllDeclaration') { + if (node.exported?.type === 'Identifier') { + names.push(node.exported.name) + } else if (node.source) { + const subResolved = await ctx.resolve( + node.source.value as string, + resolvedId, + ) + if (subResolved) { + names.push(...(await collectExportNames(ctx, subResolved.id, seen))) + } + } + } + } + return names +} + +async function expandExportAllDeclarations( + ctx: Rollup.TransformPluginContext, + ast: Awaited>, + code: string, + id: string, +): Promise<{ + code: string + ast: Awaited> +} | null> { + const targets = ast.body.filter( + (n) => n.type === 'ExportAllDeclaration' && !n.exported, + ) + if (targets.length === 0) return null + + const output = new MagicString(code) + for (const node of targets) { + if (node.type !== 'ExportAllDeclaration') continue + const source = node.source.value as string + const resolved = await ctx.resolve(source, id) + if (!resolved) continue + const names = await collectExportNames(ctx, resolved.id, new Set()) + if (names.length === 0) { + output.remove(node.start, node.end) + } else { + output.update( + node.start, + node.end, + `export { ${names.join(', ')} } from ${JSON.stringify(source)};`, + ) + } + } + if (!output.hasChanged()) return null + const newCode = output.toString() + const newAst = await parseAstAsync(newCode) + return { code: newCode, ast: newAst } +} + function vitePluginUseClient( useClientPluginOptions: Pick< RscPluginOptions, @@ -1426,7 +1548,7 @@ function vitePluginUseClient( return } - const ast = await parseAstAsync(code) + let ast = await parseAstAsync(code) if (!hasDirective(ast.body, 'use client')) { delete manager.clientReferenceMetaMap[id] return @@ -1442,6 +1564,17 @@ function vitePluginUseClient( } } + const expanded = await expandExportAllDeclarations( + this, + ast, + code, + id, + ) + if (expanded) { + code = expanded.code + ast = expanded.ast + } + let importId: string let referenceKey: string const packageSource = packageSources.get(id) @@ -1914,7 +2047,19 @@ function vitePluginUseServer( delete manager.serverReferenceMetaMap[id] return } - const ast = await parseAstAsync(code) + let ast = await parseAstAsync(code) + if (hasDirective(ast.body, 'use server')) { + const expanded = await expandExportAllDeclarations( + this, + ast, + code, + id, + ) + if (expanded) { + code = expanded.code + ast = expanded.ast + } + } let normalizedId_: string | undefined const getNormalizedId = () => { diff --git a/packages/plugin-rsc/src/transforms/proxy-export.test.ts b/packages/plugin-rsc/src/transforms/proxy-export.test.ts index 4be88ac00..4d5ce63d2 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.test.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.test.ts @@ -4,7 +4,10 @@ import { transformProxyExport } from './proxy-export' import { debugSourceMap } from './test-utils' import { transformWrapExport } from './wrap-export' -async function testTransform(input: string, options?: { keep?: boolean }) { +async function testTransform( + input: string, + options?: { keep?: boolean; ignoreExportAllDeclaration?: boolean }, +) { const ast = await parseAstAsync(input) const result = transformProxyExport(ast, { code: input, @@ -194,6 +197,54 @@ export { x as y } `) }) + test('re-export namespace', async () => { + const input = `export * as all from "./dep"` + expect(await testTransform(input)).toMatchInlineSnapshot(` + { + "exportNames": [ + "all", + ], + "output": "export const all = /* #__PURE__ */ $$proxy("", "all"); + ", + } + `) + }) + + test('re-export all (resolved)', async () => { + // when caller resolves names ahead of time, the source is rewritten so + // the transform never sees a bare `export *`. + const input = `export { x, y } from "./dep"` + expect(await testTransform(input)).toMatchInlineSnapshot(` + { + "exportNames": [ + "x", + "y", + ], + "output": "export const x = /* #__PURE__ */ $$proxy("", "x"); + export const y = /* #__PURE__ */ $$proxy("", "y"); + ", + } + `) + }) + + test('re-export all (ignoreExportAllDeclaration)', async () => { + const input = `export * from "./dep"` + expect(await testTransform(input, { ignoreExportAllDeclaration: true })) + .toMatchInlineSnapshot(` + { + "exportNames": [], + "output": "", + } + `) + }) + + test('re-export all (unresolved throws)', async () => { + const input = `export * from "./dep"` + await expect(testTransform(input)).rejects.toThrow( + 'unsupported ExportAllDeclaration', + ) + }) + test('keep', async () => { const input = `\ "use client" diff --git a/packages/plugin-rsc/src/transforms/proxy-export.ts b/packages/plugin-rsc/src/transforms/proxy-export.ts index 6f56d753d..9ba0e9cc3 100644 --- a/packages/plugin-rsc/src/transforms/proxy-export.ts +++ b/packages/plugin-rsc/src/transforms/proxy-export.ts @@ -116,13 +116,17 @@ export function transformProxyExport( } /** + * export * as ns from './foo' * export * from './foo' */ - if ( - !options.ignoreExportAllDeclaration && - node.type === 'ExportAllDeclaration' - ) { - throw new Error('unsupported ExportAllDeclaration') + if (node.type === 'ExportAllDeclaration') { + if (node.exported?.type === 'Identifier') { + createExport(node, [node.exported.name]) + continue + } + if (!options.ignoreExportAllDeclaration) { + throw new Error('unsupported ExportAllDeclaration') + } } /** diff --git a/packages/plugin-rsc/src/transforms/wrap-export.test.ts b/packages/plugin-rsc/src/transforms/wrap-export.test.ts index 1bc447226..97d9f4f29 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.test.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.test.ts @@ -207,7 +207,13 @@ export { x as y } test('re-export all rename', async () => { const input = `export * as all from "./dep"` - expect(await testTransform(input)).toMatchInlineSnapshot(`false`) + expect(await testTransform(input)).toMatchInlineSnapshot(` + "; + import * as $$import_all from "./dep"; + const $$wrap_$$import_all = /* #__PURE__ */ $$wrap($$import_all, "", "all"); + export { $$wrap_$$import_all as all }; + " + `) }) test('filter', async () => { diff --git a/packages/plugin-rsc/src/transforms/wrap-export.ts b/packages/plugin-rsc/src/transforms/wrap-export.ts index 08b1fb11c..26113c2f9 100644 --- a/packages/plugin-rsc/src/transforms/wrap-export.ts +++ b/packages/plugin-rsc/src/transforms/wrap-export.ts @@ -168,18 +168,25 @@ export function transformWrapExport( } /** + * 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 ( - !options.ignoreExportAllDeclaration && - node.type === 'ExportAllDeclaration' - ) { - throw Object.assign(new Error('unsupported ExportAllDeclaration'), { - pos: node.start, - }) + if (node.type === 'ExportAllDeclaration') { + if (node.exported?.type === 'Identifier') { + tinyassert(node.source.type === 'Literal') + const exportedName = node.exported.name + const localName = `$$import_${exportedName}` + output.remove(node.start, node.end) + toAppend.push(`import * as ${localName} from ${node.source.raw}`) + wrapExport(localName, exportedName) + } else if (!options.ignoreExportAllDeclaration) { + throw Object.assign(new Error('unsupported ExportAllDeclaration'), { + pos: node.start, + }) + } } /** From bc48c4db1fa37cf5bffa39d486c00043630e8abd Mon Sep 17 00:00:00 2001 From: James Date: Thu, 21 May 2026 22:33:25 +0100 Subject: [PATCH 2/3] fix(rsc): fall back to transformWithEsbuild for Vite 7 compat --- packages/plugin-rsc/src/plugin.ts | 34 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index d2789c642..1701ae5a0 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -23,7 +23,6 @@ import { isCSSRequest, normalizePath, parseAstAsync, - transformWithOxc, } from 'vite' import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' @@ -1377,6 +1376,30 @@ function globalAsyncLocalStoragePlugin(): Plugin[] { ] } +// Strip TS/JSX so `parseAstAsync` can read the result. Prefer oxc when +// available (Vite 8+); fall back to esbuild for older Vite versions. +async function transformSourceForExportScan( + code: string, + filename: string, +): Promise { + const v = vite as Partial<{ + transformWithOxc: ( + code: string, + filename: string, + options?: { sourcemap?: boolean }, + ) => Promise<{ code: string }> + transformWithEsbuild: ( + code: string, + filename: string, + options?: { sourcemap?: boolean }, + ) => Promise<{ code: string }> + }> + const transform = v.transformWithOxc ?? v.transformWithEsbuild + if (!transform) return undefined + const result = await transform(code, filename, { sourcemap: false }) + return result.code +} + // Recursively collect the named exports of a module (following `export * from` // chains), so that the RSC `use client`/`use server` proxy transforms can // expand bare `export *` re-exports into explicit named re-exports before @@ -1393,16 +1416,13 @@ async function collectExportNames( // `this.load`'s ModuleInfo.code is build-only. In dev mode, the dev // environment's `transformRequest` returns module-runner-specific output // (e.g. `__vite_ssr_exportName__("X", ...)` instead of `export ... from`), - // which the AST walk below can't read. Use a plain oxc pass on the source - // instead so we get standard ESM exports to walk. + // which the AST walk below can't read. Use a plain TS/JSX-aware transform + // on the source instead so we get standard ESM exports to walk. let moduleCode: string | undefined try { if (ctx.environment.mode === 'dev') { const raw = await fs.promises.readFile(resolvedId, 'utf-8') - const result = await transformWithOxc(raw, resolvedId, { - sourcemap: false, - }) - moduleCode = result.code + moduleCode = await transformSourceForExportScan(raw, resolvedId) } else { const moduleInfo = await ctx.load({ id: resolvedId }) moduleCode = moduleInfo.code ?? undefined From 87fd79867a290690e8d616090a0394480e7cfb16 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 22 May 2026 09:40:13 +0100 Subject: [PATCH 3/3] fix(rsc): use a single source-read path for export-name scan --- packages/plugin-rsc/src/plugin.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 1701ae5a0..023729878 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1413,20 +1413,16 @@ async function collectExportNames( if (seen.has(resolvedId)) return [] seen.add(resolvedId) - // `this.load`'s ModuleInfo.code is build-only. In dev mode, the dev - // environment's `transformRequest` returns module-runner-specific output - // (e.g. `__vite_ssr_exportName__("X", ...)` instead of `export ... from`), - // which the AST walk below can't read. Use a plain TS/JSX-aware transform - // on the source instead so we get standard ESM exports to walk. + // Read the source from disk and strip TS/JSX so the AST walk below sees + // standard ESM exports. We don't go through `this.load` / + // `transformRequest` here — in dev they return module-runner output + // (`__vite_ssr_exportName__(...)`) the walker can't read, and on build + // there's no practical benefit over reading the source directly for the + // simple TS/JSX modules we care about. let moduleCode: string | undefined try { - if (ctx.environment.mode === 'dev') { - const raw = await fs.promises.readFile(resolvedId, 'utf-8') - moduleCode = await transformSourceForExportScan(raw, resolvedId) - } else { - const moduleInfo = await ctx.load({ id: resolvedId }) - moduleCode = moduleInfo.code ?? undefined - } + const raw = await fs.promises.readFile(resolvedId, 'utf-8') + moduleCode = await transformSourceForExportScan(raw, resolvedId) } catch { return [] }