Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
8 changes: 8 additions & 0 deletions packages/plugin-rsc/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@
`/* color: rgb(0, 165, 255); */`,
),
)
await expect(page.locator('.test-style-server')).toHaveCSS(

Check failure on line 909 in packages/plugin-rsc/e2e/basic.test.ts

View workflow job for this annotation

GitHub Actions / test-rsc (ubuntu-latest / chromium) (react-experimental)

[chromium] › e2e/basic.test.ts:892:5 › dev-default › css hmr server

1) [chromium] › e2e/basic.test.ts:892:5 › dev-default › css hmr server ─────────────────────────── Error: expect(locator).toHaveCSS(expected) failed Locator: locator('.test-style-server') Expected: "rgb(0, 0, 0)" Received: "rgb(0, 165, 255)" Timeout: 5000ms Call log: - Expect "toHaveCSS" with timeout 5000ms - waiting for locator('.test-style-server') 14 × locator resolved to <div class="test-style-server">test-style-server</div> - unexpected value "rgb(0, 165, 255)" 907 | ), 908 | ) > 909 | await expect(page.locator('.test-style-server')).toHaveCSS( | ^ 910 | 'color', 911 | 'rgb(0, 0, 0)', 912 | ) at /home/runner/work/vite-plugin-react/vite-plugin-react/packages/plugin-rsc/e2e/basic.test.ts:909:56
'color',
'rgb(0, 0, 0)',
)
Expand Down Expand Up @@ -1874,6 +1874,14 @@
)
})

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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'

export * from './named'

export function ExportAllNamed() {
return <span data-testid="export-all-named">export-all-named</span>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client'

export function ExportAllA() {
return <span data-testid="export-all-a">export-all-a</span>
}

export function ExportAllB() {
return <span data-testid="export-all-b">export-all-b</span>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ExportAllA, ExportAllB, ExportAllNamed } from '.'

export function TestExportAll() {
return (
<div data-testid="test-export-all">
test-export-all:
<ExportAllA />|<ExportAllB />|<ExportAllNamed />
</div>
)
}
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 @@ -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'
Expand Down Expand Up @@ -125,6 +126,7 @@ export function Root(props: { url: URL }) {
<TestAssetsServer />
<TestTreeShakeServer />
<TestTreeShake2 />
<TestExportAll />
<TestClientChunkServer />
<TestChunk2 />
<TestUseId />
Expand Down
169 changes: 167 additions & 2 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
} from './plugins/vite-utils'
import {
type TransformWrapExportFilter,
extractNames,
hasDirective,
transformDirectiveProxyExport,
transformServerActionServer,
Expand Down Expand Up @@ -1375,6 +1376,147 @@ 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(
ctx: Rollup.TransformPluginContext,
resolvedId: string,
seen: Set<string>,
): Promise<string[]> {
if (seen.has(resolvedId)) return []
seen.add(resolvedId)

// `this.load`'s ModuleInfo.code is build-only. In dev mode, the dev
// environment's `transformRequest` returns module-runner-specific output
// (e.g. `__vite_ssr_exportName__("X", ...)` instead of `export ... from`),
// which the AST walk below can't read. Use a plain TS/JSX-aware transform
// on the source instead so we get standard ESM exports to walk.
let moduleCode: string | undefined
try {
if (ctx.environment.mode === 'dev') {
const raw = await fs.promises.readFile(resolvedId, 'utf-8')
moduleCode = await transformSourceForExportScan(raw, resolvedId)
} else {
const moduleInfo = await ctx.load({ id: resolvedId })
moduleCode = moduleInfo.code ?? undefined
Comment thread
james-elicx marked this conversation as resolved.
Outdated
}
} 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)};`,
)
}
}
if (!output.hasChanged()) return null
const newCode = output.toString()
const newAst = await parseAstAsync(newCode)
return { code: newCode, ast: newAst }
}

function vitePluginUseClient(
useClientPluginOptions: Pick<
RscPluginOptions,
Expand Down Expand Up @@ -1426,7 +1568,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
Expand All @@ -1442,6 +1584,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)
Expand Down Expand Up @@ -1914,7 +2067,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 = () => {
Expand Down
53 changes: 52 additions & 1 deletion packages/plugin-rsc/src/transforms/proxy-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("<id>", "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("<id>", "x");
export const y = /* #__PURE__ */ $$proxy("<id>", "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"
Expand Down
14 changes: 9 additions & 5 deletions packages/plugin-rsc/src/transforms/proxy-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-rsc/src/transforms/wrap-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<id>", "all");
export { $$wrap_$$import_all as all };
"
`)
})

test('filter', async () => {
Expand Down
Loading
Loading