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
39 changes: 37 additions & 2 deletions packages/plugin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,48 @@ Under the hood, this simply updates the React Fash Refresh runtime URL from `/@r

## React Compiler

[React Compiler](https://react.dev/learn/react-compiler) support is available via the exported `reactCompilerPreset` helper, which requires [`@rolldown/plugin-babel`](https://npmx.dev/package/@rolldown/plugin-babel) and [`babel-plugin-react-compiler`](https://npmx.dev/package/babel-plugin-react-compiler) and [`@babel/core`](https://npmx.dev/package/@babel/core) as peer dependencies:
### Rust React Compiler

> [!WARNING]
> Native React Compiler support is experimental.

The `compiler` option uses [`oxc-transform-react`](https://npmx.dev/package/oxc-transform-react), a Rust port of [React Compiler](https://react.dev/learn/react-compiler), which must be installed as an optional peer dependency:

```sh
npm install -D oxc-transform-react
```

```js
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
plugins: [react({ compiler: true })],
})
```

The `compiler` option also accepts [React Compiler options](https://react.dev/reference/react-compiler/configuration):

```js
react({ compiler: { compilationMode: 'annotation' } })
```

Source maps are generated by default during development. For production builds, the default follows Vite's [`build.sourcemap`](https://vite.dev/config/build-options.html#build-sourcemap) option. They can be disabled explicitly to improve transform performance:

```js
react({ compiler: { sourcemap: false } })
```

### Babel React Compiler

React Compiler can also be used through Babel with the exported `reactCompilerPreset` helper. This requires [`@rolldown/plugin-babel`](https://npmx.dev/package/@rolldown/plugin-babel), [`babel-plugin-react-compiler`](https://npmx.dev/package/babel-plugin-react-compiler), and [`@babel/core`](https://npmx.dev/package/@babel/core) as peer dependencies:

```sh
npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler
```

If you are using TypeScript, you will also need to install [`@types/babel__core`](https://npmx.dev/package/@types/babel__core):
If you are using TypeScript with Babel, you will also need to install [`@types/babel__core`](https://npmx.dev/package/@types/babel__core):

```sh
npm install -D @types/babel__core
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@rolldown/plugin-babel": "^0.2.3",
"@vitejs/react-common": "workspace:*",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.144.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"rolldown": "^1.2.3",
Expand All @@ -59,6 +60,7 @@
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.144.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
Expand All @@ -67,6 +69,9 @@
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
},
"engines": {
Expand Down
111 changes: 109 additions & 2 deletions packages/plugin-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
} from '@vitejs/react-common'
import type { Plugin, ServerOptions } from 'vite'
import { reactRefreshWrapperPlugin } from 'vite/internal'
import { reactCompilerPreset } from './reactCompilerPreset'
import type { ReactCompilerOptions } from '#optionalTypes'
import { defaultCodeFilter, reactCompilerPreset } from './reactCompilerPreset'

const _dirname = dirname(fileURLToPath(import.meta.url))
const refreshRuntimePath = join(_dirname, 'refresh-runtime.js')
Expand Down Expand Up @@ -52,6 +53,12 @@ export interface Options {
* reactRefreshHost: 'http://localhost:3000'
*/
reactRefreshHost?: string
/**
* Enable React Compiler with its default options or configure it.
* This requires `oxc-transform-react` to be installed.
* @default false
*/
compiler?: boolean | ReactCompilerOptions
}

const defaultIncludeRE = /\.[tj]sx?$/
Expand Down Expand Up @@ -251,7 +258,7 @@ export default function viteReact(opts: Options = {}): Plugin[] {
},
}

return [
const plugins = [
viteBabel,
viteRefreshWrapper,
viteConfigPost,
Expand All @@ -262,11 +269,111 @@ export default function viteReact(opts: Options = {}): Plugin[] {
isEnabled: () => !skipFastRefresh && !isBundledDev,
}),
]

if (opts.compiler) {
plugins.unshift(
createReactCompilerPlugin(
opts.compiler === true ? {} : opts.compiler,
include,
exclude,
),
)
}

return plugins
}

function createReactCompilerPlugin(
options: ReactCompilerOptions,
include: NonNullable<Options['include']>,
exclude: NonNullable<Options['exclude']>,
): Plugin {
const { sourcemap: sourcemapOption, ...compilerOptions } = options
let sourcemap = sourcemapOption ?? true
let compiler: typeof import('oxc-transform-react') | undefined
const runtime =
compilerOptions.target === '17' || compilerOptions.target === '18'
? 'react-compiler-runtime'
: 'react/compiler-runtime'

const loadCompiler = async (
onError: (message: string) => never,
): Promise<typeof import('oxc-transform-react')> => {
if (compiler) return compiler

try {
return (compiler = await import('oxc-transform-react'))
} catch (error) {
return onError(
`React Compiler requires the optional \`oxc-transform-react\` package. Install it in your project before enabling \`react({ compiler: true })\`.${
error instanceof Error ? `\n${error.message}` : ''
}`,
)
}
}

return {
name: 'vite:react-compiler',
enforce: 'pre',
async config() {
await loadCompiler((message) => this.error(message))
return {
optimizeDeps: {
include: [runtime],
},
}
},
configResolved(config) {
sourcemap =
sourcemapOption ??
(config.command === 'build' ? !!config.build.sourcemap : true)
},
applyToEnvironment: (env) => env.config.consumer === 'client',
transform: {
filter: {
id: {
include: makeIdFiltersToMatchWithQuery(include),
exclude: makeIdFiltersToMatchWithQuery(exclude),
},
code:
compilerOptions.compilationMode === 'annotation'
? /['"]use memo['"]/
: defaultCodeFilter,
},
async handler(code, id) {
// The config hook is not called when the plugin is used with Rolldown directly.
const { transform } =
compiler ?? (await loadCompiler((message) => this.error(message)))

const result = await transform(id.split('?')[0]!, code, {
jsx: 'preserve',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Having this plugin doing this transformation with JSX preserve and then the builtin rolldown doing the jsx transformation will make the jsxDev output have wrong line numbers. This breaks various plugin and browser extensions that are using this to jump from the client to the editor in dev mode.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re both comments:

oxc-transform-react is intended to provide or react related transforms.

For @vitejs/plugin-react, I want to keep the blast radius minimal so it only uses the react compiler transform from oxc-transform-react.

Having this plugin doing this transformation with JSX preserve and then the builtin rolldown doing the jsx transformation will make the jsxDev output have wrong line numbers.

Is this a bug that I should fix? Does it happen with babel react compiler as well?

reactCompiler: compilerOptions,
sourcemap,
})
const diagnostics = result.errors.map(
(error) =>
`${error.message}${error.codeframe ? `\n${error.codeframe}` : ''}`,
)

if (result.fatal) {
this.error(
diagnostics.join('\n\n') || 'React Compiler transform failed.',
)
}
for (const diagnostic of diagnostics) {
this.warn(diagnostic)
}

return { code: result.code, map: result.map }
},
},
}
}

viteReact.preambleCode = preambleCode

export { reactCompilerPreset }
export type { ReactCompilerOptions }

// Compat for require
function viteReactForCjs(this: unknown, options: Options): Plugin[] {
Expand Down
139 changes: 139 additions & 0 deletions packages/plugin-react/tests/reactCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import path from 'node:path'
import { type Plugin, rolldown } from 'rolldown'
import { describe, expect, test } from 'vitest'
import pluginReact, {
type Options,
type ReactCompilerOptions,
} from '../src/index.ts'

describe('compiler option', () => {
test('compiles React components', async () => {
const output = await bundle(
{ compiler: true },
`
export function App({ name }: { name: string }) {
return <div>{name}</div>
}
`,
)

expect(output.code).toContain('react/compiler-runtime')
expect(output.code).toMatch(/\bc\(2\)/)
expect(
output.map?.sources.some((source) => source.endsWith('entry.tsx')),
).toBe(true)
})

test('forwards compiler options', async () => {
const code = `
export function App({ name }) {
return <div>{name}</div>
}
`
const unannotated = await bundle(
{ compiler: { compilationMode: 'annotation' } },
code,
)
const annotated = await bundle(
{ compiler: { compilationMode: 'annotation' } },
`
export function App({ name }) {
'use memo'
return <div>{name}</div>
}
`,
)
const react18 = await bundle({ compiler: { target: '18' } }, code)

expect(unannotated.code).not.toContain('react/compiler-runtime')
expect(annotated.code).toContain('react/compiler-runtime')
expect(react18.code).toContain('react-compiler-runtime')
})

test('uses the React plugin filters', async () => {
const output = await bundle(
{ compiler: true, exclude: /entry\.tsx$/ },
`export function App({ name }) { return <div>{name}</div> }`,
)

expect(output.code).not.toContain('react/compiler-runtime')
})

test('uses the Vite build sourcemap setting by default', async () => {
expect((await transformWithBuildConfig({}, false)).map).toBeFalsy()
expect((await transformWithBuildConfig({}, true)).map).toBeTruthy()
expect(
(await transformWithBuildConfig({ sourcemap: false }, true)).map,
).toBeFalsy()
expect(
(await transformWithBuildConfig({ sourcemap: true }, false)).map,
).toBeTruthy()
})
})

async function transformWithBuildConfig(
compiler: ReactCompilerOptions,
buildSourcemap: boolean,
) {
const plugin = pluginReact({ compiler }).find(
(plugin) => plugin.name === 'vite:react-compiler',
)!
const context = {
error(message: unknown): never {
throw new Error(String(message))
},
warn() {},
}

if (typeof plugin.config !== 'function')
throw new Error('Missing config hook')
await plugin.config.call(
context as any,
{},
{ command: 'build', mode: 'production' },
)

if (typeof plugin.configResolved !== 'function') {
throw new Error('Missing configResolved hook')
}
await plugin.configResolved.call(
context as any,
{
command: 'build',
build: { sourcemap: buildSourcemap },
} as any,
)

if (typeof plugin.transform !== 'object') {
throw new Error('Missing transform hook')
}
return plugin.transform.handler.call(
context as any,
`export function App({ name }) { return <div>{name}</div> }`,
'/entry.tsx',
)
}

async function bundle(options: Options, code: string) {
const entry = '/entry.tsx'
const build = await rolldown({
input: entry,
plugins: [virtualFilePlugin(entry, code), pluginReact(options)],
external: [/^react(\/|$)/, /^react-compiler-runtime$/],
})
const { output } = await build.generate({ format: 'esm', sourcemap: true })
return output[0]
}

function virtualFilePlugin(entry: string, code: string): Plugin {
return {
name: 'virtual-file',
resolveId(id, importer) {
const baseDir = importer ? path.posix.dirname(importer) : '/'
if (path.posix.resolve(baseDir, id) === entry) return entry
},
load(id) {
if (id === entry) return code
},
}
}
10 changes: 10 additions & 0 deletions packages/plugin-react/types/optionalTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@
import type * as pluginBabel from '@rolldown/plugin-babel'
// @ts-ignore --- `babel-plugin-react-compiler` is an optional peer dependency, so this may cause an error
import type * as babelPluginReactCompiler from 'babel-plugin-react-compiler'
// @ts-ignore --- `oxc-transform-react` is an optional peer dependency, so this may cause an error
import type * as oxcTransformReact from 'oxc-transform-react'

// @ts-ignore --- `@rolldown/plugin-babel` is an optional peer dependency, so this may cause an error
export type RolldownBabelPreset = pluginBabel.RolldownBabelPreset
// @ts-ignore --- `babel-plugin-react-compiler` is an optional peer dependency, so this may cause an error
export type ReactCompilerBabelPluginOptions =
babelPluginReactCompiler.PluginOptions
// @ts-ignore --- `oxc-transform-react` is an optional peer dependency, so this may cause an error
export type ReactCompilerOptions = oxcTransformReact.ReactCompilerOptions & {
/**
* Generate a source map for the compiler transform.
* @default true during development, `build.sourcemap` during builds
*/
sourcemap?: boolean
}
Loading
Loading