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, + }) + } } /**