Skip to content

Commit dd94ec6

Browse files
authored
fix(rsc): handle export * re-exports in use client and use server modules (#1234)
1 parent 40dff34 commit dd94ec6

10 files changed

Lines changed: 281 additions & 16 deletions

File tree

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,6 +1874,14 @@ function defineTest(f: Fixture) {
18741874
)
18751875
})
18761876

1877+
test('export *', async ({ page }) => {
1878+
await page.goto(f.url())
1879+
await waitForHydration(page)
1880+
await expect(page.getByTestId('test-export-all')).toHaveText(
1881+
'test-export-all:export-all-a|export-all-b|export-all-named',
1882+
)
1883+
})
1884+
18771885
test('virtual module with use client', async ({ page }) => {
18781886
await page.goto(f.url())
18791887
await waitForHydration(page)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use client'
2+
3+
export * from './named'
4+
5+
export function ExportAllNamed() {
6+
return <span data-testid="export-all-named">export-all-named</span>
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client'
2+
3+
export function ExportAllA() {
4+
return <span data-testid="export-all-a">export-all-a</span>
5+
}
6+
7+
export function ExportAllB() {
8+
return <span data-testid="export-all-b">export-all-b</span>
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ExportAllA, ExportAllB, ExportAllNamed } from '.'
2+
3+
export function TestExportAll() {
4+
return (
5+
<div data-testid="test-export-all">
6+
test-export-all:
7+
<ExportAllA />|<ExportAllB />|<ExportAllNamed />
8+
</div>
9+
)
10+
}

packages/plugin-rsc/examples/basic/src/routes/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { TestClientInServer } from './deps/client-in-server/server'
2929
import { TestServerInClient } from './deps/server-in-client/client'
3030
import { TestServerInServer } from './deps/server-in-server/server'
3131
import { TestTransitiveCjsClient } from './deps/transitive-cjs/client'
32+
import { TestExportAll } from './export-all/server'
3233
import { TestHmrClientDep } from './hmr-client-dep/client'
3334
import { TestHmrClientDep2 } from './hmr-client-dep2/client'
3435
import { TestHmrClientDep3 } from './hmr-client-dep3/server'
@@ -125,6 +126,7 @@ export function Root(props: { url: URL }) {
125126
<TestAssetsServer />
126127
<TestTreeShakeServer />
127128
<TestTreeShake2 />
129+
<TestExportAll />
128130
<TestClientChunkServer />
129131
<TestChunk2 />
130132
<TestUseId />

packages/plugin-rsc/src/plugin.ts

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
} from './plugins/vite-utils'
6666
import {
6767
type TransformWrapExportFilter,
68+
extractNames,
6869
hasDirective,
6970
transformDirectiveProxyExport,
7071
transformServerActionServer,
@@ -1375,6 +1376,143 @@ function globalAsyncLocalStoragePlugin(): Plugin[] {
13751376
]
13761377
}
13771378

1379+
// Strip TS/JSX so `parseAstAsync` can read the result. Prefer oxc when
1380+
// available (Vite 8+); fall back to esbuild for older Vite versions.
1381+
async function transformSourceForExportScan(
1382+
code: string,
1383+
filename: string,
1384+
): Promise<string | undefined> {
1385+
const v = vite as Partial<{
1386+
transformWithOxc: (
1387+
code: string,
1388+
filename: string,
1389+
options?: { sourcemap?: boolean },
1390+
) => Promise<{ code: string }>
1391+
transformWithEsbuild: (
1392+
code: string,
1393+
filename: string,
1394+
options?: { sourcemap?: boolean },
1395+
) => Promise<{ code: string }>
1396+
}>
1397+
const transform = v.transformWithOxc ?? v.transformWithEsbuild
1398+
if (!transform) return undefined
1399+
const result = await transform(code, filename, { sourcemap: false })
1400+
return result.code
1401+
}
1402+
1403+
// Recursively collect the named exports of a module (following `export * from`
1404+
// chains), so that the RSC `use client`/`use server` proxy transforms can
1405+
// expand bare `export *` re-exports into explicit named re-exports before
1406+
// proxy generation. The pure proxy transform cannot do this on its own because
1407+
// the names live in another file.
1408+
async function collectExportNames(
1409+
ctx: Rollup.TransformPluginContext,
1410+
resolvedId: string,
1411+
seen: Set<string>,
1412+
): Promise<string[]> {
1413+
if (seen.has(resolvedId)) return []
1414+
seen.add(resolvedId)
1415+
1416+
// Read the source from disk and strip TS/JSX so the AST walk below sees
1417+
// standard ESM exports. We don't go through `this.load` /
1418+
// `transformRequest` here — in dev they return module-runner output
1419+
// (`__vite_ssr_exportName__(...)`) the walker can't read, and on build
1420+
// there's no practical benefit over reading the source directly for the
1421+
// simple TS/JSX modules we care about.
1422+
let moduleCode: string | undefined
1423+
try {
1424+
const raw = await fs.promises.readFile(resolvedId, 'utf-8')
1425+
moduleCode = await transformSourceForExportScan(raw, resolvedId)
1426+
} catch {
1427+
return []
1428+
}
1429+
if (!moduleCode) return []
1430+
1431+
let ast: Awaited<ReturnType<typeof parseAstAsync>>
1432+
try {
1433+
ast = await parseAstAsync(moduleCode)
1434+
} catch {
1435+
return []
1436+
}
1437+
1438+
const names: string[] = []
1439+
for (const node of ast.body) {
1440+
if (node.type === 'ExportNamedDeclaration') {
1441+
if (node.declaration) {
1442+
if (
1443+
node.declaration.type === 'FunctionDeclaration' ||
1444+
node.declaration.type === 'ClassDeclaration'
1445+
) {
1446+
if (node.declaration.id) names.push(node.declaration.id.name)
1447+
} else if (node.declaration.type === 'VariableDeclaration') {
1448+
for (const decl of node.declaration.declarations) {
1449+
names.push(...extractNames(decl.id))
1450+
}
1451+
}
1452+
} else {
1453+
for (const spec of node.specifiers) {
1454+
if (
1455+
spec.exported.type === 'Identifier' &&
1456+
spec.exported.name !== 'default'
1457+
) {
1458+
names.push(spec.exported.name)
1459+
}
1460+
}
1461+
}
1462+
} else if (node.type === 'ExportAllDeclaration') {
1463+
if (node.exported?.type === 'Identifier') {
1464+
names.push(node.exported.name)
1465+
} else if (node.source) {
1466+
const subResolved = await ctx.resolve(
1467+
node.source.value as string,
1468+
resolvedId,
1469+
)
1470+
if (subResolved) {
1471+
names.push(...(await collectExportNames(ctx, subResolved.id, seen)))
1472+
}
1473+
}
1474+
}
1475+
}
1476+
return names
1477+
}
1478+
1479+
async function expandExportAllDeclarations(
1480+
ctx: Rollup.TransformPluginContext,
1481+
ast: Awaited<ReturnType<typeof parseAstAsync>>,
1482+
code: string,
1483+
id: string,
1484+
): Promise<{
1485+
code: string
1486+
ast: Awaited<ReturnType<typeof parseAstAsync>>
1487+
} | null> {
1488+
const targets = ast.body.filter(
1489+
(n) => n.type === 'ExportAllDeclaration' && !n.exported,
1490+
)
1491+
if (targets.length === 0) return null
1492+
1493+
const output = new MagicString(code)
1494+
for (const node of targets) {
1495+
if (node.type !== 'ExportAllDeclaration') continue
1496+
const source = node.source.value as string
1497+
const resolved = await ctx.resolve(source, id)
1498+
if (!resolved) continue
1499+
const names = await collectExportNames(ctx, resolved.id, new Set())
1500+
if (names.length === 0) {
1501+
output.remove(node.start, node.end)
1502+
} else {
1503+
output.update(
1504+
node.start,
1505+
node.end,
1506+
`export { ${names.join(', ')} } from ${JSON.stringify(source)};`,
1507+
)
1508+
}
1509+
}
1510+
if (!output.hasChanged()) return null
1511+
const newCode = output.toString()
1512+
const newAst = await parseAstAsync(newCode)
1513+
return { code: newCode, ast: newAst }
1514+
}
1515+
13781516
function vitePluginUseClient(
13791517
useClientPluginOptions: Pick<
13801518
RscPluginOptions,
@@ -1426,7 +1564,7 @@ function vitePluginUseClient(
14261564
return
14271565
}
14281566

1429-
const ast = await parseAstAsync(code)
1567+
let ast = await parseAstAsync(code)
14301568
if (!hasDirective(ast.body, 'use client')) {
14311569
delete manager.clientReferenceMetaMap[id]
14321570
return
@@ -1442,6 +1580,17 @@ function vitePluginUseClient(
14421580
}
14431581
}
14441582

1583+
const expanded = await expandExportAllDeclarations(
1584+
this,
1585+
ast,
1586+
code,
1587+
id,
1588+
)
1589+
if (expanded) {
1590+
code = expanded.code
1591+
ast = expanded.ast
1592+
}
1593+
14451594
let importId: string
14461595
let referenceKey: string
14471596
const packageSource = packageSources.get(id)
@@ -1914,7 +2063,19 @@ function vitePluginUseServer(
19142063
delete manager.serverReferenceMetaMap[id]
19152064
return
19162065
}
1917-
const ast = await parseAstAsync(code)
2066+
let ast = await parseAstAsync(code)
2067+
if (hasDirective(ast.body, 'use server')) {
2068+
const expanded = await expandExportAllDeclarations(
2069+
this,
2070+
ast,
2071+
code,
2072+
id,
2073+
)
2074+
if (expanded) {
2075+
code = expanded.code
2076+
ast = expanded.ast
2077+
}
2078+
}
19182079

19192080
let normalizedId_: string | undefined
19202081
const getNormalizedId = () => {

packages/plugin-rsc/src/transforms/proxy-export.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { transformProxyExport } from './proxy-export'
44
import { debugSourceMap } from './test-utils'
55
import { transformWrapExport } from './wrap-export'
66

7-
async function testTransform(input: string, options?: { keep?: boolean }) {
7+
async function testTransform(
8+
input: string,
9+
options?: { keep?: boolean; ignoreExportAllDeclaration?: boolean },
10+
) {
811
const ast = await parseAstAsync(input)
912
const result = transformProxyExport(ast, {
1013
code: input,
@@ -194,6 +197,54 @@ export { x as y }
194197
`)
195198
})
196199

200+
test('re-export namespace', async () => {
201+
const input = `export * as all from "./dep"`
202+
expect(await testTransform(input)).toMatchInlineSnapshot(`
203+
{
204+
"exportNames": [
205+
"all",
206+
],
207+
"output": "export const all = /* #__PURE__ */ $$proxy("<id>", "all");
208+
",
209+
}
210+
`)
211+
})
212+
213+
test('re-export all (resolved)', async () => {
214+
// when caller resolves names ahead of time, the source is rewritten so
215+
// the transform never sees a bare `export *`.
216+
const input = `export { x, y } from "./dep"`
217+
expect(await testTransform(input)).toMatchInlineSnapshot(`
218+
{
219+
"exportNames": [
220+
"x",
221+
"y",
222+
],
223+
"output": "export const x = /* #__PURE__ */ $$proxy("<id>", "x");
224+
export const y = /* #__PURE__ */ $$proxy("<id>", "y");
225+
",
226+
}
227+
`)
228+
})
229+
230+
test('re-export all (ignoreExportAllDeclaration)', async () => {
231+
const input = `export * from "./dep"`
232+
expect(await testTransform(input, { ignoreExportAllDeclaration: true }))
233+
.toMatchInlineSnapshot(`
234+
{
235+
"exportNames": [],
236+
"output": "",
237+
}
238+
`)
239+
})
240+
241+
test('re-export all (unresolved throws)', async () => {
242+
const input = `export * from "./dep"`
243+
await expect(testTransform(input)).rejects.toThrow(
244+
'unsupported ExportAllDeclaration',
245+
)
246+
})
247+
197248
test('keep', async () => {
198249
const input = `\
199250
"use client"

packages/plugin-rsc/src/transforms/proxy-export.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,17 @@ export function transformProxyExport(
116116
}
117117

118118
/**
119+
* export * as ns from './foo'
119120
* export * from './foo'
120121
*/
121-
if (
122-
!options.ignoreExportAllDeclaration &&
123-
node.type === 'ExportAllDeclaration'
124-
) {
125-
throw new Error('unsupported ExportAllDeclaration')
122+
if (node.type === 'ExportAllDeclaration') {
123+
if (node.exported?.type === 'Identifier') {
124+
createExport(node, [node.exported.name])
125+
continue
126+
}
127+
if (!options.ignoreExportAllDeclaration) {
128+
throw new Error('unsupported ExportAllDeclaration')
129+
}
126130
}
127131

128132
/**

packages/plugin-rsc/src/transforms/wrap-export.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,13 @@ export { x as y }
207207

208208
test('re-export all rename', async () => {
209209
const input = `export * as all from "./dep"`
210-
expect(await testTransform(input)).toMatchInlineSnapshot(`false`)
210+
expect(await testTransform(input)).toMatchInlineSnapshot(`
211+
";
212+
import * as $$import_all from "./dep";
213+
const $$wrap_$$import_all = /* #__PURE__ */ $$wrap($$import_all, "<id>", "all");
214+
export { $$wrap_$$import_all as all };
215+
"
216+
`)
211217
})
212218

213219
test('filter', async () => {

packages/plugin-rsc/src/transforms/wrap-export.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,25 @@ export function transformWrapExport(
168168
}
169169

170170
/**
171+
* export * as ns from './foo'
171172
* export * from './foo'
172173
*/
173174
// vue sfc uses ExportAllDeclaration to re-export setup script.
174175
// for now we just give an option to not throw for this case.
175176
// https://github.com/vitejs/vite-plugin-vue/blob/30a97c1ddbdfb0e23b7dc14a1d2fb609668b9987/packages/plugin-vue/src/main.ts#L372
176-
if (
177-
!options.ignoreExportAllDeclaration &&
178-
node.type === 'ExportAllDeclaration'
179-
) {
180-
throw Object.assign(new Error('unsupported ExportAllDeclaration'), {
181-
pos: node.start,
182-
})
177+
if (node.type === 'ExportAllDeclaration') {
178+
if (node.exported?.type === 'Identifier') {
179+
tinyassert(node.source.type === 'Literal')
180+
const exportedName = node.exported.name
181+
const localName = `$$import_${exportedName}`
182+
output.remove(node.start, node.end)
183+
toAppend.push(`import * as ${localName} from ${node.source.raw}`)
184+
wrapExport(localName, exportedName)
185+
} else if (!options.ignoreExportAllDeclaration) {
186+
throw Object.assign(new Error('unsupported ExportAllDeclaration'), {
187+
pos: node.start,
188+
})
189+
}
183190
}
184191

185192
/**

0 commit comments

Comments
 (0)