diff --git a/packages/plugin-react/README.md b/packages/plugin-react/README.md index 1799fc321..bb4e684dc 100644 --- a/packages/plugin-react/README.md +++ b/packages/plugin-react/README.md @@ -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: diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index b86d7c37b..d69219c1f 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -278,8 +278,11 @@ export default function viteReact(opts: Options = {}): Plugin[] { opts.compiler === true ? {} : opts.compiler, include, exclude, - opts, - () => !skipFastRefresh, + { + standalone: false, + reactOptions: opts, + isFastRefreshEnabled: () => !skipFastRefresh, + }, ), ) } @@ -287,12 +290,53 @@ export default function viteReact(opts: Options = {}): Plugin[] { 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 + isFastRefreshEnabled: () => boolean + } + function createReactCompilerPlugin( options: ReactCompilerOptions, include: NonNullable, exclude: NonNullable, - reactOptions: Pick, - isFastRefreshEnabled: () => boolean, + host: ReactCompilerPluginHost, ): Plugin { let sourcemap = true let jsxDevelopment = false @@ -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, @@ -311,9 +359,9 @@ 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}` : ''}`, ) } } @@ -321,6 +369,12 @@ function createReactCompilerPlugin( 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 { @@ -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, }) @@ -393,5 +448,6 @@ function viteReactForCjs(this: unknown, options: Options): Plugin[] { Object.assign(viteReactForCjs, { default: viteReactForCjs, reactCompilerPreset, + reactCompiler, }) export { viteReactForCjs as 'module.exports' } diff --git a/packages/plugin-react/src/reactCompiler.test.ts b/packages/plugin-react/src/reactCompiler.test.ts new file mode 100644 index 000000000..dd58e3d52 --- /dev/null +++ b/packages/plugin-react/src/reactCompiler.test.ts @@ -0,0 +1,123 @@ +import type { Plugin } from 'vite' +import { describe, expect, test } from 'vitest' +import react, { reactCompiler } from './index' + +type TransformHook = Extract +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, + code, + id, + ) + return { transform, run } +} + +const component = ` +import { useState } from 'react' + +export function App({ title }: { title: string }) { + const [count, setCount] = useState(0) + return +} +` + +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(' { + 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(' { + const { run } = getTransform(plugin) + const code = (await run( + 'export const el =
', + '/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() + }) +}) diff --git a/playground/compiler-standalone/__tests__/compiler-standalone.spec.ts b/playground/compiler-standalone/__tests__/compiler-standalone.spec.ts new file mode 100644 index 000000000..48bd3c8ed --- /dev/null +++ b/playground/compiler-standalone/__tests__/compiler-standalone.spec.ts @@ -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!') +}) diff --git a/playground/compiler-standalone/index.html b/playground/compiler-standalone/index.html new file mode 100644 index 000000000..e4b78eae1 --- /dev/null +++ b/playground/compiler-standalone/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/playground/compiler-standalone/package.json b/playground/compiler-standalone/package.json new file mode 100644 index 000000000..118e4b84a --- /dev/null +++ b/playground/compiler-standalone/package.json @@ -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" + } +} diff --git a/playground/compiler-standalone/public/vite.svg b/playground/compiler-standalone/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/playground/compiler-standalone/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/playground/compiler-standalone/src/App.css b/playground/compiler-standalone/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/playground/compiler-standalone/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/playground/compiler-standalone/src/App.tsx b/playground/compiler-standalone/src/App.tsx new file mode 100644 index 000000000..07f8d0add --- /dev/null +++ b/playground/compiler-standalone/src/App.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react' +import './App.css' +import { ClassComponent } from './ClassComponent' + +export function App() { + const [count, setCount] = useState(0) + + return ( + <> +

Vite + React Compiler

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + + ) +} diff --git a/playground/compiler-standalone/src/ClassComponent.tsx b/playground/compiler-standalone/src/ClassComponent.tsx new file mode 100644 index 000000000..c0f0e39a4 --- /dev/null +++ b/playground/compiler-standalone/src/ClassComponent.tsx @@ -0,0 +1,7 @@ +import { Component } from 'react' + +export class ClassComponent extends Component { + public render() { + return
ClassComponent
+ } +} diff --git a/playground/compiler-standalone/src/index.css b/playground/compiler-standalone/src/index.css new file mode 100644 index 000000000..2c3fac689 --- /dev/null +++ b/playground/compiler-standalone/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/playground/compiler-standalone/src/main.tsx b/playground/compiler-standalone/src/main.tsx new file mode 100644 index 000000000..813e3d764 --- /dev/null +++ b/playground/compiler-standalone/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/playground/compiler-standalone/tsconfig.json b/playground/compiler-standalone/tsconfig.json new file mode 100644 index 000000000..195fe84e7 --- /dev/null +++ b/playground/compiler-standalone/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM"], + "types": ["vite/client"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/playground/compiler-standalone/vite.config.ts b/playground/compiler-standalone/vite.config.ts new file mode 100644 index 000000000..ca910914a --- /dev/null +++ b/playground/compiler-standalone/vite.config.ts @@ -0,0 +1,11 @@ +import react, { reactCompiler } from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// The standalone plugin is meant for setups where JSX and Fast Refresh are +// owned by another plugin (e.g. React Router framework mode). Here `react()` +// plays that role, so this mirrors the `compiler` playground with the native +// standalone plugin in place of the Babel preset. +export default defineConfig({ + server: { port: 8912 /* Should be unique */ }, + plugins: [react(), reactCompiler()], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c1a8cd52..22b43cba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1248,6 +1248,31 @@ importers: specifier: ^6.0.3 version: 6.0.3 + playground/compiler-standalone: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: workspace:* + version: link:../../packages/plugin-react + oxc-transform-react: + specifier: ^0.145.0 + version: 0.145.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + playground/hmr-false: dependencies: react: