Skip to content

Commit d618f63

Browse files
hi-ogawacodex
andauthored
fix(rsc): handle conflict names from export * in use client and use server modules (#1239)
Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com>
1 parent dd94ec6 commit d618f63

76 files changed

Lines changed: 573 additions & 250 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,27 @@ function defineTest(f: Fixture) {
376376
)
377377
})
378378

379+
test('use server export all @js', async ({ page }) => {
380+
await page.goto(f.url())
381+
await waitForHydration(page)
382+
383+
await expect(page.getByTestId('test-action-export-all-server')).toHaveText(
384+
'export-all-server-to-server: 0',
385+
)
386+
await page.getByTestId('test-action-export-all-server').click()
387+
await expect(page.getByTestId('test-action-export-all-server')).toHaveText(
388+
'export-all-server-to-server: 1',
389+
)
390+
391+
await expect(page.getByTestId('test-action-export-all-client')).toHaveText(
392+
'export-all-server-to-client: ?',
393+
)
394+
await page.getByTestId('test-action-export-all-client').click()
395+
await expect(page.getByTestId('test-action-export-all-client')).toHaveText(
396+
'export-all-server-to-client: export-all-client',
397+
)
398+
})
399+
379400
test('useActionState with jsx @js', async ({ page }) => {
380401
await page.goto(f.url())
381402
await waitForHydration(page)
@@ -1874,7 +1895,7 @@ function defineTest(f: Fixture) {
18741895
)
18751896
})
18761897

1877-
test('export *', async ({ page }) => {
1898+
test('export all', async ({ page }) => {
18781899
await page.goto(f.url())
18791900
await waitForHydration(page)
18801901
await expect(page.getByTestId('test-export-all')).toHaveText(
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
let serverValue = 0
2+
3+
export async function readExportAllServerValue() {
4+
return serverValue
5+
}
6+
7+
export async function incrementExportAllServerValue() {
8+
serverValue++
9+
}
10+
11+
export async function getExportAllClientValue() {
12+
return 'export-all-client'
13+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use server'
2+
3+
export * from './action-impl'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client'
2+
3+
import React from 'react'
4+
import { getExportAllClientValue } from './actions'
5+
6+
export function TestActionExportAllClient() {
7+
const [result, setResult] = React.useState('?')
8+
9+
return (
10+
<button
11+
data-testid="test-action-export-all-client"
12+
onClick={async () => {
13+
setResult(await getExportAllClientValue())
14+
}}
15+
>
16+
export-all-server-to-client: {result}
17+
</button>
18+
)
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
incrementExportAllServerValue,
3+
readExportAllServerValue,
4+
} from './actions'
5+
import { TestActionExportAllClient } from './client'
6+
7+
export function TestActionExportAll() {
8+
return (
9+
<>
10+
<form action={incrementExportAllServerValue}>
11+
<button data-testid="test-action-export-all-server">
12+
export-all-server-to-server: {readExportAllServerValue()}
13+
</button>
14+
</form>
15+
<TestActionExportAllClient />
16+
</>
17+
)
18+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
TestServerActionBindMember,
1010
} from './action-bind/server'
1111
import { TestServerActionError } from './action-error/server'
12+
import { TestActionExportAll } from './action-export-all/server'
1213
import {
1314
TestActionFromClient,
1415
TestNonFormActionArgs,
@@ -101,6 +102,7 @@ export function Root(props: { url: URL }) {
101102
<TestReplayConsoleLogs url={props.url} />
102103
<TestSuspense url={props.url} />
103104
<TestActionFromClient />
105+
<TestActionExportAll />
104106
<TestUseActionState />
105107
<TestNonFormActionError />
106108
<TestNonFormActionArgs />

packages/plugin-rsc/src/plugin.ts

Lines changed: 32 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,13 @@ import {
6565
} from './plugins/vite-utils'
6666
import {
6767
type TransformWrapExportFilter,
68-
extractNames,
6968
hasDirective,
7069
transformDirectiveProxyExport,
70+
transformExpandExportAll,
7171
transformServerActionServer,
7272
transformWrapExport,
7373
findDirectives,
74+
type TransformExpandExportAllContext,
7475
} from './transforms'
7576
import { generateEncryptionKey, toBase64 } from './utils/encryption-utils'
7677
import { createRpcServer } from './utils/rpc'
@@ -1376,141 +1377,26 @@ function globalAsyncLocalStoragePlugin(): Plugin[] {
13761377
]
13771378
}
13781379

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(
1380+
function createTransformExpandExportAllContext(
14091381
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-
}
1382+
): TransformExpandExportAllContext {
1383+
return {
1384+
resolve: async (source, importer) => {
1385+
return (await ctx.resolve(source, importer))?.id
1386+
},
1387+
load: async (id) => {
1388+
// Read the source from disk and strip TS/JSX so the AST walk sees
1389+
// standard ESM exports. We don't go through `this.load` /
1390+
// `transformRequest` here — in dev they return module-runner output
1391+
// (`__vite_ssr_exportName__(...)`) the walker can't read, and on build
1392+
// there's no practical benefit over reading the source directly for the
1393+
// simple TS/JSX modules we care about.
1394+
const raw = await fs.promises.readFile(id, 'utf-8')
1395+
const transform = vite.transformWithOxc ?? vite.transformWithEsbuild
1396+
const result = await transform(raw, id, { sourcemap: false })
1397+
return parseAstAsync(result.code)
1398+
},
15091399
}
1510-
if (!output.hasChanged()) return null
1511-
const newCode = output.toString()
1512-
const newAst = await parseAstAsync(newCode)
1513-
return { code: newCode, ast: newAst }
15141400
}
15151401

15161402
function vitePluginUseClient(
@@ -1580,15 +1466,15 @@ function vitePluginUseClient(
15801466
}
15811467
}
15821468

1583-
const expanded = await expandExportAllDeclarations(
1584-
this,
1585-
ast,
1469+
const expanded = await transformExpandExportAll({
15861470
code,
1587-
id,
1588-
)
1471+
ast,
1472+
importer: id,
1473+
...createTransformExpandExportAllContext(this),
1474+
})
15891475
if (expanded) {
15901476
code = expanded.code
1591-
ast = expanded.ast
1477+
ast = await parseAstAsync(code)
15921478
}
15931479

15941480
let importId: string
@@ -2065,15 +1951,15 @@ function vitePluginUseServer(
20651951
}
20661952
let ast = await parseAstAsync(code)
20671953
if (hasDirective(ast.body, 'use server')) {
2068-
const expanded = await expandExportAllDeclarations(
2069-
this,
2070-
ast,
1954+
const expanded = await transformExpandExportAll({
20711955
code,
2072-
id,
2073-
)
1956+
ast,
1957+
importer: id,
1958+
...createTransformExpandExportAllContext(this),
1959+
})
20741960
if (expanded) {
20751961
code = expanded.code
2076-
ast = expanded.ast
1962+
ast = await parseAstAsync(code)
20771963
}
20781964
}
20791965

0 commit comments

Comments
 (0)