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..023729878 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -65,6 +65,7 @@ import {
} from './plugins/vite-utils'
import {
type TransformWrapExportFilter,
+ extractNames,
hasDirective,
transformDirectiveProxyExport,
transformServerActionServer,
@@ -1375,6 +1376,143 @@ 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
+// 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)
+
+ // 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 {
+ const raw = await fs.promises.readFile(resolvedId, 'utf-8')
+ moduleCode = await transformSourceForExportScan(raw, resolvedId)
+ } 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 +1564,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 +1580,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 +2063,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,
+ })
+ }
}
/**