Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 on lines +1423 to +1428
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think switching to this.load on build is practically meaningful. Can we start with minimal js/tsx support?

}
} 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