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
24 changes: 24 additions & 0 deletions packages/plugin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,30 @@ The `compiler` option also accepts [React Compiler options](https://react.dev/re
react({ compiler: { compilationMode: 'annotation' } })
```

#### Standalone plugin

If another plugin already handles JSX and Fast Refresh (for example React Router in framework mode, whose Vite plugin replaces `react()`), use the standalone `reactCompiler` plugin instead. It only runs the React Compiler, in client environments, and preserves JSX for the rest of your setup. It is the native counterpart of the `reactCompilerPreset` Babel helper below.

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

export default defineConfig({
plugins: [reactRouter(), reactCompiler()],
})
```

`reactCompiler` accepts the same React Compiler options as the `compiler` option, plus `include` and `exclude` with the same defaults as the main plugin:

```js
reactCompiler({
compilationMode: 'annotation',
exclude: [/\/node_modules\//, /\/legacy\//],
})
```

### 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:
Expand Down
92 changes: 74 additions & 18 deletions packages/plugin-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,21 +278,65 @@ export default function viteReact(opts: Options = {}): Plugin[] {
opts.compiler === true ? {} : opts.compiler,
include,
exclude,
opts,
() => !skipFastRefresh,
{
standalone: false,
reactOptions: opts,
isFastRefreshEnabled: () => !skipFastRefresh,
},
),
)
}

return plugins
}

export interface ReactCompilerPluginOptions extends ReactCompilerOptions {
/**
* Same as the `include` option of the main plugin
* @default /\.[tj]sx?$/
*/
include?: Options['include']
/**
* Same as the `exclude` option of the main plugin
* @default /\/node_modules\//
*/
exclude?: Options['exclude']
}

/**
* Standalone React Compiler plugin for setups where another plugin already
* handles JSX and Fast Refresh (e.g. React Router framework mode), so the
* main plugin cannot be added. It only runs the React Compiler, in client
* environments, and preserves JSX for the rest of the pipeline.
* This requires `oxc-transform-react` to be installed.
* @experimental
*/
export function reactCompiler(
options: ReactCompilerPluginOptions = {},
): Plugin {
const {
include = defaultIncludeRE,
exclude = defaultExcludeRE,
...compilerOptions
} = options
return createReactCompilerPlugin(compilerOptions, include, exclude, {
standalone: true,
})
}

type ReactCompilerPluginHost =
| { standalone: true }
| {
standalone: false
reactOptions: Pick<Options, 'jsxRuntime' | 'jsxImportSource'>
isFastRefreshEnabled: () => boolean
}

function createReactCompilerPlugin(
options: ReactCompilerOptions,
include: NonNullable<Options['include']>,
exclude: NonNullable<Options['exclude']>,
reactOptions: Pick<Options, 'jsxRuntime' | 'jsxImportSource'>,
isFastRefreshEnabled: () => boolean,
host: ReactCompilerPluginHost,
): Plugin {
let sourcemap = true
let jsxDevelopment = false
Expand All @@ -301,6 +345,10 @@ function createReactCompilerPlugin(
options.target === '17' || options.target === '18'
? 'react-compiler-runtime'
: 'react/compiler-runtime'
const codeFilter =
options.compilationMode === 'annotation'
? /['"]use memo['"]/
: defaultCodeFilter

const loadCompiler = async (
onError: (message: string) => never,
Expand All @@ -311,16 +359,22 @@ function createReactCompilerPlugin(
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}` : ''
}`,
`React Compiler requires the optional \`oxc-transform-react\` package. Install it in your project before enabling \`${
host.standalone ? 'reactCompiler()' : 'react({ compiler: true })'
}\`.${error instanceof Error ? `\n${error.message}` : ''}`,
)
}
}

return {
name: 'vite:react-compiler',
enforce: 'pre',
...(host.standalone
? {
// Nothing to do for server environments when JSX is not transformed here.
applyToEnvironment: (env) => env.config.consumer === 'client',
}
: {}),
async config() {
await loadCompiler((message) => this.error(message))
return {
Expand All @@ -339,25 +393,26 @@ function createReactCompilerPlugin(
include: makeIdFiltersToMatchWithQuery(include),
exclude: makeIdFiltersToMatchWithQuery(exclude),
},
// The main plugin must still transform JSX in files the compiler skips.
...(host.standalone ? { code: codeFilter } : {}),
},
async handler(code, id) {
const isClient = this.environment?.config.consumer !== 'server'
const shouldCompile =
isClient &&
(options.compilationMode === 'annotation'
? /['"]use memo['"]/.test(code)
: defaultCodeFilter.test(code))
const shouldCompile = isClient && codeFilter.test(code)
if (host.standalone && !shouldCompile) return
// 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: {
runtime: reactOptions.jsxRuntime,
development: jsxDevelopment,
importSource: reactOptions.jsxImportSource,
refresh: isClient && isFastRefreshEnabled(),
},
jsx: host.standalone
? 'preserve'
: {
runtime: host.reactOptions.jsxRuntime,
development: jsxDevelopment,
importSource: host.reactOptions.jsxImportSource,
refresh: isClient && host.isFastRefreshEnabled(),
},
reactCompiler: shouldCompile ? options : false,
sourcemap,
})
Expand Down Expand Up @@ -393,5 +448,6 @@ function viteReactForCjs(this: unknown, options: Options): Plugin[] {
Object.assign(viteReactForCjs, {
default: viteReactForCjs,
reactCompilerPreset,
reactCompiler,
})
export { viteReactForCjs as 'module.exports' }
123 changes: 123 additions & 0 deletions packages/plugin-react/src/reactCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Plugin } from 'vite'
import { describe, expect, test } from 'vitest'
import react, { reactCompiler } from './index'

type TransformHook = Extract<Plugin['transform'], { handler: unknown }>
type TransformHandler = TransformHook['handler']

function getTransform(plugin: Plugin) {
const transform = plugin.transform as TransformHook
const run = (code: string, id: string, consumer: 'client' | 'server') =>
(transform.handler as TransformHandler).call(
{
environment: { config: { consumer } },
error(message: string) {
throw new Error(message)
},
warn() {},
} as unknown as ThisParameterType<TransformHandler>,
code,
id,
)
return { transform, run }
}

const component = `
import { useState } from 'react'

export function App({ title }: { title: string }) {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{title}: {count}</button>
}
`

describe('reactCompiler', () => {
test('compiles components and preserves JSX', async () => {
const { run } = getTransform(reactCompiler())
const result = await run(component, '/src/App.tsx', 'client')
expect(result).toBeTruthy()
const code = (result as { code: string }).code
expect(code).toContain('react/compiler-runtime')
expect(code).toMatch(/_c\(\d+\)/)
// JSX is left for the JSX transform of the rest of the pipeline
expect(code).toContain('<button')
// TypeScript is removed alongside
expect(code).not.toContain('title: string')
})

test('is a pre plugin with a code filter and client-only environments', () => {
const plugin = reactCompiler()
const { transform } = getTransform(plugin)
expect(plugin.enforce).toBe('pre')
expect(transform.filter?.code).toBeDefined()
const applyToEnvironment = plugin.applyToEnvironment as (env: {
config: { consumer: string }
}) => boolean
expect(applyToEnvironment({ config: { consumer: 'client' } })).toBe(true)
expect(applyToEnvironment({ config: { consumer: 'server' } })).toBe(false)
})

test('skips modules that cannot contain components or hooks', async () => {
const { run } = getTransform(reactCompiler())
expect(
await run('export const answer = 42', '/src/answer.ts', 'client'),
).toBeUndefined()
})

test('skips server environments', async () => {
const { run } = getTransform(reactCompiler())
expect(await run(component, '/src/App.tsx', 'server')).toBeUndefined()
})

test('honors annotation mode', async () => {
const { run } = getTransform(
reactCompiler({ compilationMode: 'annotation' }),
)
expect(await run(component, '/src/App.tsx', 'client')).toBeUndefined()
const annotated = component.replace(
'const [count',
'"use memo"\n const [count',
)
const result = await run(annotated, '/src/App.tsx', 'client')
expect((result as { code: string }).code).toMatch(/_c\(\d+\)/)
})

test('uses react-compiler-runtime for older targets', async () => {
const { run } = getTransform(reactCompiler({ target: '18' }))
const result = await run(component, '/src/App.tsx', 'client')
expect((result as { code: string }).code).toContain(
'react-compiler-runtime',
)
})
})

describe('react({ compiler: true })', () => {
const plugin = react({ compiler: true }).find(
(p) => p.name === 'vite:react-compiler',
)!

test('compiles components and transforms JSX', async () => {
const { run } = getTransform(plugin)
const code = (await run(component, '/src/App.tsx', 'client')) as {
code: string
}
expect(code.code).toMatch(/_c\(\d+\)/)
expect(code.code).toContain('react/jsx-runtime')
expect(code.code).not.toContain('<button')
})

test('still transforms JSX in files the compiler skips', async () => {
const { run } = getTransform(plugin)
const code = (await run(
'export const el = <div />',
'/src/el.tsx',
'server',
)) as { code: string }
expect(code.code).toContain('react/jsx-runtime')
expect(code.code).not.toContain('compiler-runtime')
})

test('is not environment-gated', () => {
expect(plugin.applyToEnvironment).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from 'vitest'
import { editFile, isBuild, isServe, page, testDir, viteServer } from '~utils'

test('should render', async () => {
expect(await page.textContent('button')).toMatch('count is 0')
expect(await page.click('button'))
expect(await page.textContent('button')).toMatch('count is 1')
expect(await page.textContent('.class-component')).toMatch('ClassComponent')
})

test.runIf(isServe)('should compile components', async () => {
const result =
await viteServer.environments.client.transformRequest('/src/App.tsx')
// The runtime import is rewritten to the pre-bundled dependency path in dev
expect(result?.code).toMatch(/compiler-runtime/)
expect(result?.code).toMatch(/_c\(\d+\)/)
})

test.runIf(isBuild)('should compile components', () => {
const assetsDir = path.join(testDir, 'dist/assets')
const bundle = fs
.readdirSync(assetsDir)
.filter((file) => file.endsWith('.js'))
.map((file) => fs.readFileSync(path.join(assetsDir, file), 'utf-8'))
.join('\n')
expect(bundle).toContain('react.memo_cache_sentinel')
})

test.runIf(isServe)('should hmr', async () => {
editFile('src/App.tsx', (code) =>
code.replace('count is {count}', 'count is {count}!'),
)
await expect.poll(() => page.textContent('button')).toMatch('count is 1!')
})
13 changes: 13 additions & 0 deletions playground/compiler-standalone/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
21 changes: 21 additions & 0 deletions playground/compiler-standalone/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@vitejs/test-compiler-standalone",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "workspace:*",
"oxc-transform-react": "^0.145.0",
"typescript": "^6.0.3"
}
}
1 change: 1 addition & 0 deletions playground/compiler-standalone/public/vite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading