Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
6fe4bc5
refactor(rsc): extract export-all expansion transform
hi-ogawa May 29, 2026
be07f71
test(rsc): cover export-all expansion transform
hi-ogawa May 29, 2026
c2c31d1
nit
hi-ogawa May 29, 2026
bf242cb
refactor(rsc): simplify export-all transform hooks
hi-ogawa May 29, 2026
37cb86f
refactor(rsc): simplify export-all loader result
hi-ogawa May 29, 2026
d549696
test(rsc): document export-all expansion edge cases
hi-ogawa May 29, 2026
8121e62
test(rsc): simplify export-all fixtures
hi-ogawa May 29, 2026
39c71a7
nit
hi-ogawa May 29, 2026
8ba64be
nit
hi-ogawa May 29, 2026
67e2286
nit
hi-ogawa May 29, 2026
7a9aaf2
fix(rsc): keep namespace re-exports unsupported for wrapping
hi-ogawa May 29, 2026
7c40e46
test(rsc): record string export name behavior
hi-ogawa May 29, 2026
3d055ed
fix(rsc): report unsupported string export names
hi-ogawa May 29, 2026
0fa246a
nit
hi-ogawa May 29, 2026
1e07465
test(rsc): cover use-server export-all e2e
hi-ogawa May 29, 2026
9c31881
test(rsc): polish export-all e2e fixture
hi-ogawa May 29, 2026
70c5438
fix(rsc): surface export-all scan failures
hi-ogawa May 29, 2026
d770194
refactor(rsc): simplify export scan transform lookup
hi-ogawa May 29, 2026
6560672
test(rsc): normalize export-all load error fixture
hi-ogawa May 29, 2026
fb48f55
nit
hi-ogawa May 29, 2026
950f9f7
nit
hi-ogawa May 29, 2026
2f281ae
fix(rsc): handle export-all name conflicts
hi-ogawa May 29, 2026
a569552
wip: nested filter fix
hi-ogawa Jun 1, 2026
a4ef28e
fix(rsc): propagate nested export star ambiguity
hi-ogawa Jun 1, 2026
ef2ab8c
comment
hi-ogawa Jun 1, 2026
7159f2f
test: more wip
hi-ogawa Jun 1, 2026
3eded9b
test(rsc): document export star identity edge cases
hi-ogawa Jun 1, 2026
8f11854
refactor(rsc): inline export-all scan wrapper
hi-ogawa Jun 1, 2026
a8f126d
test: trim snapshot
hi-ogawa Jun 1, 2026
4108ed6
fix(rsc): reject string export names in export star scan
hi-ogawa Jun 1, 2026
05487c8
fix(rsc): preserve export star side effects
hi-ogawa Jun 1, 2026
ed62407
nit
hi-ogawa Jun 1, 2026
b556acd
nit
hi-ogawa Jun 1, 2026
d46e7fa
nit
hi-ogawa Jun 1, 2026
e9ee15e
nit
hi-ogawa Jun 1, 2026
a06ea0c
test(rsc): cover circular export star expansion
hi-ogawa Jun 1, 2026
b5d63b5
refactor(rsc): inline export-all source resolution
hi-ogawa Jun 1, 2026
74ac57b
test(rsc): document cyclic export star limitation
hi-ogawa Jun 1, 2026
912b215
nit
hi-ogawa Jun 1, 2026
07d6f0e
nit
hi-ogawa Jun 1, 2026
4c91f04
refactor(rsc): split export-all transform context
hi-ogawa Jun 1, 2026
6c550df
nit
hi-ogawa Jun 1, 2026
8ce5fec
nit
hi-ogawa Jun 1, 2026
1bb9c41
test(rsc): cover export-all no-op transform
hi-ogawa Jun 1, 2026
2a9db03
comment
hi-ogawa Jun 1, 2026
254ec89
chore: remove unused broken getExportNames
hi-ogawa Jun 1, 2026
cfbff92
nit
hi-ogawa Jun 1, 2026
0aac838
nit
hi-ogawa Jun 1, 2026
3b9eb96
nit
hi-ogawa Jun 1, 2026
1d269ba
nit
hi-ogawa Jun 1, 2026
b861b7b
test(rsc): cover explicit re-export over nested ambiguity
hi-ogawa Jun 1, 2026
a2e0cbb
refactor(rsc): simplify ambiguous star export tracking
hi-ogawa Jun 1, 2026
c14d59e
test: remove useless test
hi-ogawa Jun 1, 2026
6ff198d
fix(rsc): attach positions to string export errors
hi-ogawa Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,27 @@ function defineTest(f: Fixture) {
)
})

test('use server export all @js', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)

await expect(page.getByTestId('test-action-export-all-server')).toHaveText(
'export-all-server-to-server: 0',
)
await page.getByTestId('test-action-export-all-server').click()
await expect(page.getByTestId('test-action-export-all-server')).toHaveText(
'export-all-server-to-server: 1',
)

await expect(page.getByTestId('test-action-export-all-client')).toHaveText(
'export-all-server-to-client: ?',
)
await page.getByTestId('test-action-export-all-client').click()
await expect(page.getByTestId('test-action-export-all-client')).toHaveText(
'export-all-server-to-client: export-all-client',
)
})

test('useActionState with jsx @js', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
Expand Down Expand Up @@ -1874,7 +1895,7 @@ function defineTest(f: Fixture) {
)
})

test('export *', async ({ page }) => {
test('export all', async ({ page }) => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.getByTestId('test-export-all')).toHaveText(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
let serverValue = 0

export async function readExportAllServerValue() {
return serverValue
}

export async function incrementExportAllServerValue() {
serverValue++
}

export async function getExportAllClientValue() {
return 'export-all-client'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use server'

export * from './action-impl'
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'

import React from 'react'
import { getExportAllClientValue } from './actions'

export function TestActionExportAllClient() {
const [result, setResult] = React.useState('?')

return (
<button
data-testid="test-action-export-all-client"
onClick={async () => {
setResult(await getExportAllClientValue())
}}
>
export-all-server-to-client: {result}
</button>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
incrementExportAllServerValue,
readExportAllServerValue,
} from './actions'
import { TestActionExportAllClient } from './client'

export function TestActionExportAll() {
return (
<>
<form action={incrementExportAllServerValue}>
<button data-testid="test-action-export-all-server">
export-all-server-to-server: {readExportAllServerValue()}
</button>
</form>
<TestActionExportAllClient />
</>
)
}
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/basic/src/routes/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TestServerActionBindMember,
} from './action-bind/server'
import { TestServerActionError } from './action-error/server'
import { TestActionExportAll } from './action-export-all/server'
import {
TestActionFromClient,
TestNonFormActionArgs,
Expand Down Expand Up @@ -101,6 +102,7 @@ export function Root(props: { url: URL }) {
<TestReplayConsoleLogs url={props.url} />
<TestSuspense url={props.url} />
<TestActionFromClient />
<TestActionExportAll />
<TestUseActionState />
<TestNonFormActionError />
<TestNonFormActionArgs />
Expand Down
178 changes: 32 additions & 146 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ import {
} from './plugins/vite-utils'
import {
type TransformWrapExportFilter,
extractNames,
hasDirective,
transformDirectiveProxyExport,
transformExpandExportAll,
transformServerActionServer,
transformWrapExport,
findDirectives,
type TransformExpandExportAllContext,
} from './transforms'
import { generateEncryptionKey, toBase64 } from './utils/encryption-utils'
import { createRpcServer } from './utils/rpc'
Expand Down Expand Up @@ -1376,141 +1377,26 @@ 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<string | undefined> {
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(
function createTransformExpandExportAllContext(
ctx: Rollup.TransformPluginContext,
resolvedId: string,
seen: Set<string>,
): Promise<string[]> {
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<ReturnType<typeof parseAstAsync>>
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<ReturnType<typeof parseAstAsync>>,
code: string,
id: string,
): Promise<{
code: string
ast: Awaited<ReturnType<typeof parseAstAsync>>
} | 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)};`,
)
}
): TransformExpandExportAllContext {
return {
resolve: async (source, importer) => {
return (await ctx.resolve(source, importer))?.id
},
load: async (id) => {
// Read the source from disk and strip TS/JSX so the AST walk 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.
const raw = await fs.promises.readFile(id, 'utf-8')
const transform = vite.transformWithOxc ?? vite.transformWithEsbuild
const result = await transform(raw, id, { sourcemap: false })
return parseAstAsync(result.code)
},
}
if (!output.hasChanged()) return null
const newCode = output.toString()
const newAst = await parseAstAsync(newCode)
return { code: newCode, ast: newAst }
}

function vitePluginUseClient(
Expand Down Expand Up @@ -1580,15 +1466,15 @@ function vitePluginUseClient(
}
}

const expanded = await expandExportAllDeclarations(
this,
ast,
const expanded = await transformExpandExportAll({
code,
id,
)
ast,
importer: id,
...createTransformExpandExportAllContext(this),
})
if (expanded) {
code = expanded.code
ast = expanded.ast
ast = await parseAstAsync(code)
}

let importId: string
Expand Down Expand Up @@ -2065,15 +1951,15 @@ function vitePluginUseServer(
}
let ast = await parseAstAsync(code)
if (hasDirective(ast.body, 'use server')) {
const expanded = await expandExportAllDeclarations(
this,
ast,
const expanded = await transformExpandExportAll({
code,
id,
)
ast,
importer: id,
...createTransformExpandExportAllContext(this),
})
if (expanded) {
code = expanded.code
ast = expanded.ast
ast = await parseAstAsync(code)
}
}

Expand Down
Loading
Loading