Skip to content
Merged
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
48 changes: 48 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,54 @@ Note that while there are official npm packages [`server-only`](https://www.npmj

This build-time validation is enabled by default and can be disabled by setting `validateImports: false` in the plugin options.

#### Barrel packages

A server component that imports one component from a package's barrel entry can fail with `'client-only' cannot be imported in server build`, even though the component it uses is fine on the server:

```tsx
import { Button } from 'some-ui-library' // ❌ fails because of `Calendar`, which imports 'client-only'
```

```js
// node_modules/some-ui-library/index.js
export { Button } from './button.js'
export { Calendar } from './calendar.js'
```

The validation runs on the module graph before tree-shaking, so the unused `calendar.js` and its `client-only` import are still part of the graph, even when the package has `"sideEffects": false`. Dev fails in the same way, because Vite evaluates the barrel and every module it re-exports. Disabling `validateImports` doesn't fix dev reliably either, because other client-only code in an unused module, such as a top-level `createContext` call, can still crash the server.

To fix this, import from the package's subpath entry instead, for example `import { Button } from 'some-ui-library/button'`. To do this without changing each import, rewrite barrel imports with [`@rolldown/plugin-transform-imports`](https://github.com/rolldown/plugins/tree/main/packages/transform-imports), which applies in both dev and build and requires Vite 8:

```js
import transformImports from '@rolldown/plugin-transform-imports'

export default defineConfig({
plugins: [
transformImports({
'some-ui-library': {
transform: [
// `Button` -> `some-ui-library/button`
['[A-Z].*', 'some-ui-library/{{kebabCase member}}'],
// other exports, such as hooks, stay on the barrel entry
['*', 'some-ui-library'],
],
skipDefaultConversion: true,
},
}),
rsc(),
react(),
],
})
```

When configuring the plugin:

- Check that the member-to-subpath mapping matches the package's `exports`. A member that matches no pattern is rewritten to the member name itself, so keep the `'*'` fallback.
- Set `skipDefaultConversion: true` when the subpath entries use named exports. Otherwise the plugin rewrites `import { Button }` to a default import.
- The plugin only rewrites code that Vite transforms, which excludes pre-bundled dependencies. Packages with a `react` peer dependency are not pre-bundled in server environments, so imports of a typical UI library are covered, but a pre-bundled dependency that imports the barrel itself isn't rewritten.

If the subpath entry itself still reaches client-only code, rewriting imports doesn't help. Move that usage into a `"use client"` component instead.

## Credits

This project builds on fundamental techniques and insights from pioneering Vite RSC implementations.
Expand Down
130 changes: 130 additions & 0 deletions packages/plugin-rsc/e2e/validate-imports.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test'
import { x } from 'tinyexec'
import * as vite from 'vite'
import { setupInlineFixture, useFixture, type Fixture } from './fixture'
import { expectNoPageError, waitForHydration } from './helper'

Expand Down Expand Up @@ -161,3 +162,132 @@ test.describe('validate imports', () => {
})
})
})

// A server component importing one export from a `sideEffects: false` barrel
// package still pulls in every leaf the barrel re-exports, including one that
// imports `client-only`. Rewriting barrel imports to subpath imports with
// `@rolldown/plugin-transform-imports` keeps unused leaves out of the module
// graph in both dev and build.
test.describe('client-only in barrel package', () => {
function setupBarrelFixture(options: {
root: string
transformImports: boolean
}) {
return setupInlineFixture({
src: 'examples/starter-extra',
dest: options.root,
files: {
// the package is installed through examples/e2e/package.json, but the plugin
// only crawls the app's own package.json to find React packages to bundle in
// server environments, so list it here as well
'package.json': {
edit: (s) => {
const pkg = JSON.parse(s)
pkg.dependencies['@vitejs/test-dep-barrel'] = '*'
return JSON.stringify(pkg, null, 2)
},
},
'src/root.tsx': /* tsx */ `
import { Button } from '@vitejs/test-dep-barrel'

export function Root() {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<Button>[test-barrel-button]</Button>
</body>
</html>
)
}
`,
...(options.transformImports && {
'vite.config.ts': {
edit: (s) =>
s
.replace(
`import { defineConfig } from 'vite'`,
`import { defineConfig } from 'vite'\nimport transformImports from '@rolldown/plugin-transform-imports'`,
)
.replace(
`plugins: [`,
`plugins: [
transformImports({
'@vitejs/test-dep-barrel': {
transform: [
['[A-Z].*', '@vitejs/test-dep-barrel/{{kebabCase member}}'],
['*', '@vitejs/test-dep-barrel'],
],
skipDefaultConversion: true,
},
}),
`,
),
},
}),
},
})
}

test.describe('without transform', () => {
const root = 'examples/e2e/temp/validate-client-only-barrel'
test.beforeAll(async () => {
await setupBarrelFixture({ root, transformImports: false })
})

test.describe('dev', () => {
const f = useFixture({ root, mode: 'dev' })

test('basic', async ({ request }) => {
const res = await request.get(f.url())
expect(res.status()).toBe(500)
expect(await res.text()).toContain(
`'client-only' cannot be imported in server build`,
)
})
})

test('build', async () => {
const result = await x('pnpm', ['build'], {
throwOnError: false,
nodeOptions: { cwd: root },
})
expect(result.stderr).toContain(`rsc:validate-imports`)
expect(result.stderr).toContain(
`'client-only' cannot be imported in server build`,
)
expect(result.exitCode).not.toBe(0)
})
})

test.describe('with transform', () => {
test.skip(!('rolldownVersion' in vite), 'rolldown only')

const root = 'examples/e2e/temp/validate-client-only-barrel-transform'
test.beforeAll(async () => {
await setupBarrelFixture({ root, transformImports: true })
})

test.describe('dev', () => {
const f = useFixture({ root, mode: 'dev' })
defineTest(f)
})

test.describe('build', () => {
const f = useFixture({ root, mode: 'build' })
defineTest(f)
})

function defineTest(f: Fixture) {
test('basic', async ({ page }) => {
using _ = expectNoPageError(page)
await page.goto(f.url())
await expect(page.getByRole('button')).toHaveText(
'[test-barrel-button]',
)
})
}
})
})
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"devDependencies": {
"@cloudflare/vite-plugin": "^1.62.5",
"@rolldown/plugin-babel": "^0.2.4",
"@rolldown/plugin-transform-imports": "^0.1.2",
"@vitejs/plugin-react": "latest",
"@vitejs/plugin-rsc": "latest",
"@vitejs/test-dep-barrel": "file:./test-dep/barrel",
"babel-plugin-react-compiler": "^1.0.0",
"connect": "^3.7.0",
"rsc-html-stream": "^0.0.8",
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-rsc/examples/e2e/test-dep/barrel/button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react'

export function Button(props) {
return React.createElement('button', null, props.children)
}
6 changes: 6 additions & 0 deletions packages/plugin-rsc/examples/e2e/test-dep/barrel/calendar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import 'client-only'
import React from 'react'

export function Calendar() {
return React.createElement('div', null, '[test-calendar]')
}
2 changes: 2 additions & 0 deletions packages/plugin-rsc/examples/e2e/test-dep/barrel/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Button } from './button.js'
export { Calendar } from './calendar.js'
14 changes: 14 additions & 0 deletions packages/plugin-rsc/examples/e2e/test-dep/barrel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@vitejs/test-dep-barrel",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./index.js",
"./button": "./button.js",
"./calendar": "./calendar.js"
},
"peerDependencies": {
"react": "*"
}
}
47 changes: 47 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading