diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index be7e8d607..92879dd25 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -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. diff --git a/packages/plugin-rsc/e2e/validate-imports.test.ts b/packages/plugin-rsc/e2e/validate-imports.test.ts index edce8c868..bc82f9984 100644 --- a/packages/plugin-rsc/e2e/validate-imports.test.ts +++ b/packages/plugin-rsc/e2e/validate-imports.test.ts @@ -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' @@ -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 ( + + + + + + + + + ) + } + `, + ...(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]', + ) + }) + } + }) +}) diff --git a/packages/plugin-rsc/examples/e2e/package.json b/packages/plugin-rsc/examples/e2e/package.json index 9bd59da85..b8954a6eb 100644 --- a/packages/plugin-rsc/examples/e2e/package.json +++ b/packages/plugin-rsc/examples/e2e/package.json @@ -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", diff --git a/packages/plugin-rsc/examples/e2e/test-dep/barrel/button.js b/packages/plugin-rsc/examples/e2e/test-dep/barrel/button.js new file mode 100644 index 000000000..a288254a9 --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/test-dep/barrel/button.js @@ -0,0 +1,5 @@ +import React from 'react' + +export function Button(props) { + return React.createElement('button', null, props.children) +} diff --git a/packages/plugin-rsc/examples/e2e/test-dep/barrel/calendar.js b/packages/plugin-rsc/examples/e2e/test-dep/barrel/calendar.js new file mode 100644 index 000000000..4e174dcc5 --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/test-dep/barrel/calendar.js @@ -0,0 +1,6 @@ +import 'client-only' +import React from 'react' + +export function Calendar() { + return React.createElement('div', null, '[test-calendar]') +} diff --git a/packages/plugin-rsc/examples/e2e/test-dep/barrel/index.js b/packages/plugin-rsc/examples/e2e/test-dep/barrel/index.js new file mode 100644 index 000000000..ea38f579e --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/test-dep/barrel/index.js @@ -0,0 +1,2 @@ +export { Button } from './button.js' +export { Calendar } from './calendar.js' diff --git a/packages/plugin-rsc/examples/e2e/test-dep/barrel/package.json b/packages/plugin-rsc/examples/e2e/test-dep/barrel/package.json new file mode 100644 index 000000000..7b45b796e --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/test-dep/barrel/package.json @@ -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": "*" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1e39690..6dd77a8e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,12 +729,18 @@ importers: '@rolldown/plugin-babel': specifier: ^0.2.4 version: 0.2.4(@babel/core@8.0.6)(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.1)) + '@rolldown/plugin-transform-imports': + specifier: ^0.1.2 + version: 0.1.2(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.1)) '@vitejs/plugin-react': specifier: latest version: link:../../../plugin-react '@vitejs/plugin-rsc': specifier: latest version: link:../.. + '@vitejs/test-dep-barrel': + specifier: file:./test-dep/barrel + version: file:packages/plugin-rsc/examples/e2e/test-dep/barrel(react@19.3.1) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -2642,6 +2648,16 @@ packages: vite: optional: true + '@rolldown/plugin-transform-imports@0.1.2': + resolution: {integrity: sha512-C6I8jO2Eg2LfBkzwR9ZEKv6I2rtSETL/psCnGHNHqOENJwBHeEAYPyq9RhDqEO8sF6RtLwlpDsbg6lu76YxT3w==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + rolldown: ^1.0.0-rc.13 + vite: ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -3099,6 +3115,11 @@ packages: '@vitejs/release-scripts@1.10.0': resolution: {integrity: sha512-1vPdK4Jm2vxCFpaf4D7Nwam677OUoXqUU0WqhLjYec38qiK/XXkzuWK6QnFkn9ty63F+slN13pNUCwEHPOLRuQ==} + '@vitejs/test-dep-barrel@file:packages/plugin-rsc/examples/e2e/test-dep/barrel': + resolution: {directory: packages/plugin-rsc/examples/e2e/test-dep/barrel, type: directory} + peerDependencies: + react: '*' + '@vitejs/test-dep-cjs-events-extend@file:packages/plugin-rsc/examples/basic/test-dep/cjs-events-extend': resolution: {directory: packages/plugin-rsc/examples/basic/test-dep/cjs-events-extend, type: directory} peerDependencies: @@ -4531,6 +4552,15 @@ packages: vue-tsc: optional: true + rolldown-string@0.3.1: + resolution: {integrity: sha512-dv8GOXkYqUQdI0rsXB8hmzO95pKWSuX2eg5/JSJa9czfEVIFuKNR7ZJDfat8LlmD35RAhWY+evS7/wXXqFDfSg==} + engines: {node: '>=20.19.0'} + peerDependencies: + rolldown: '*' + peerDependenciesMeta: + rolldown: + optional: true + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5926,6 +5956,13 @@ snapshots: optionalDependencies: vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.1) + '@rolldown/plugin-transform-imports@0.1.2(rolldown@1.2.12)(vite@8.3.2(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.1))': + dependencies: + rolldown: 1.2.12 + rolldown-string: 0.3.1(rolldown@1.2.12) + optionalDependencies: + vite: 8.3.2(@types/node@24.19.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.1) + '@rolldown/pluginutils@1.0.1': {} '@rollup/pluginutils@5.1.4': @@ -6280,6 +6317,10 @@ snapshots: semver: 7.8.5 tinyexec: 1.3.1 + '@vitejs/test-dep-barrel@file:packages/plugin-rsc/examples/e2e/test-dep/barrel(react@19.3.1)': + dependencies: + react: 19.3.1 + '@vitejs/test-dep-cjs-events-extend@file:packages/plugin-rsc/examples/basic/test-dep/cjs-events-extend(react@19.3.1)': dependencies: react: 19.3.1 @@ -7775,6 +7816,12 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown-string@0.3.1(rolldown@1.2.12): + dependencies: + magic-string: 1.4.3 + optionalDependencies: + rolldown: 1.2.12 + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0