13.3 · difficulty 4/4 · 20 min read
Source Walkthrough: `@vitejs/plugin-react` v6
Use the official React plugin for Vite 8 to understand Oxc JSX configuration, Fast Refresh runtime, preamble handling, SSR entrypoints, and Babel migration boundaries.
Correct A Common Misread
@vitejs/plugin-react v6 is not "the old Babel transform moved behind Oxc". Its main path is:
- Configure Vite's Oxc pipeline for JSX runtime, import source, and Fast Refresh.
- Install the Fast Refresh preamble and runtime module through Vite/React shared helpers.
- Keep common React project options such as
include,exclude,jsxImportSource,jsxRuntime, andreactRefreshHost. - Remove the old direct
babeloption from v5; when you need Babel behavior, add@rolldown/plugin-babelexplicitly.
So when reading v6 source, do not look only for one huge transform() hook. The important part is how the plugin configures Vite's Oxc capabilities and attaches the Refresh runtime.
The Plugin Array Shape
Tutorials often simplify the plugin to:
react() -> [jsxTransformPlugin, refreshPlugin]v6 is closer to this shape:
export default function viteReact(options: Options = {}): Plugin[] {
return [
viteReactOxcConfig,
viteRefreshWrapper,
viteConfigPost,
viteReactRefreshBundledDevMode,
viteReactRefresh,
virtualPreamblePlugin(...),
]
}Each layer has a separate job:
| Layer | Main responsibility |
|---|---|
| Oxc config layer | Configure JSX and Refresh from jsxRuntime, jsxImportSource, include, and exclude |
| Refresh wrapper | Wrap React modules with Fast Refresh HMR logic in dev/client environments |
| Config post layer | Disable refresh when server.hmr === false |
| Bundled dev mode preamble | Inject the preamble early in full bundle mode |
| Refresh runtime module | Expose the /@react-refresh runtime |
| Preamble virtual plugin | Support explicit @vitejs/plugin-react/preamble imports |
This is typical Vite plugin design. Configuration, HTML injection, virtual modules, and environment filtering belong in different hooks instead of one large transform.
Options Define The Boundary
The core v6 options split into file scope and JSX behavior:
interface Options {
include?: string | RegExp | Array<string | RegExp>
exclude?: string | RegExp | Array<string | RegExp>
jsxImportSource?: string
jsxRuntime?: "classic" | "automatic"
reactRefreshHost?: string
}These options affect two paths:
- Oxc JSX transform: automatic/classic runtime, import source, and whether refresh is enabled.
- Refresh wrapper: which files should receive Fast Refresh wrapping and which files must be excluded.
Excluding node_modules by default matters. Dependency source shape is not controlled by your app, and blindly wrapping it can make HMR unpredictable and slow down dev transforms.
What The Oxc Config Layer Does
v6 returns Vite config from the config() hook so Vite's internal Oxc pipeline handles JSX:
const viteReactOxcConfig: Plugin = {
name: "vite:react-babel",
enforce: "pre",
config(_userConfig, { command }) {
return {
oxc: {
jsx: {
runtime: "automatic",
importSource: options.jsxImportSource,
refresh: command === "serve",
},
jsxRefreshInclude: makeIdFiltersToMatchWithQuery(include),
jsxRefreshExclude: makeIdFiltersToMatchWithQuery(exclude),
},
optimizeDeps: {
rolldownOptions: {
transform: {
jsx: { runtime: "automatic" },
},
},
},
}
},
}The plugin name still contains babel, but that does not mean v6 uses Babel as the primary transform. The name is historical; the returned oxc configuration is the important part.
If jsxRuntime: "classic" is set, the config switches to classic runtime. Most new projects should stay on automatic runtime.
Why Fast Refresh Needs A Runtime
React Fast Refresh is not just "rerun this module". It needs runtime state to track component signatures, check whether exports are still compatible, and refresh component boundaries safely.
In development, the plugin needs three pieces:
- Install the Refresh runtime when the page starts.
- Wrap transformed React modules with HMR accept logic.
- Invalidate HMR when exports no longer satisfy Fast Refresh rules.
Conceptually:
if (import.meta.hot) {
import.meta.hot.accept((nextModule) => {
if (isRefreshBoundary(nextModule)) {
enqueueUpdate()
} else {
import.meta.hot.invalidate()
}
})
}The real implementation is more complex because it handles component signatures, export consistency, error recovery, sourcemaps, and multiple environments. When reading the source, first identify that the wrapper exists to decide whether React state can be preserved after a module update.
/@react-refresh And The Preamble
/@react-refresh is the runtime module served by the dev server. The preamble installs the global hook before the app entry runs:
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = trueFor a normal Vite SPA, the plugin can inject this through transformIndexHtml. SSR and backend-integrated projects are the tricky cases:
- HTML is rendered by Express, Rails, Laravel, Django, or another backend.
- The project does not let Vite call
transformIndexHtml. - The React entry runs before the preamble has been installed.
That leads to errors such as "plugin-react can't detect preamble". There are two fixes:
import "@vitejs/plugin-react/preamble"Or call transformIndexHtml in the SSR dev server:
html = await viteServer.transformIndexHtml(req.url, html)This is why 7.8 · Backend Integration covers the React preamble separately. It is not decorative; it is a startup requirement for Fast Refresh.
The reactRefreshHost Option
reactRefreshHost mainly exists for module federation / remote app setups. Imagine the host runs at http://localhost:3000 and the remote runs at http://localhost:3001. If both install separate refresh runtimes, React Refresh state can diverge.
Configure the remote to use the host runtime:
import react from "@vitejs/plugin-react"
export default {
plugins: [
react({
reactRefreshHost: "http://localhost:3000",
}),
],
}If the host uses base, include that base in the URL. Otherwise the browser will request /@react-refresh from the wrong path.
Migrating From v5 Babel Options
The migration rule is simple: do not treat Babel as an internal option of the React plugin anymore. If you need Babel plugins, add Babel as a separate Rolldown/Vite pipeline plugin.
React Compiler is the common example:
import { defineConfig } from "vite"
import react, { reactCompilerPreset } from "@vitejs/plugin-react"
import babel from "@rolldown/plugin-babel"
export default defineConfig({
plugins: [
react(),
babel({
presets: [reactCompilerPreset()],
}),
],
})Migration checklist:
| v5 habit | v6 approach |
|---|---|
react({ babel: { plugins: [...] } }) | Move it to @rolldown/plugin-babel |
| Custom JSX import source | Keep using react({ jsxImportSource: "..." }) |
| Old classic JSX runtime | Use react({ jsxRuntime: "classic" }) |
| MDX also needs Refresh | Adjust include and ensure the MDX plugin runs before React |
SSR does not call transformIndexHtml | Import @vitejs/plugin-react/preamble in the client entry |
This boundary matters. The React plugin owns the default React + Oxc + Refresh experience; Babel extension work is composed through a separate plugin.
Debugging Order
For React + Vite dev issues, check in this order:
- Is the preamble present? Search for
__vite_plugin_react_preamble_installed__in browser devtools. - Does
@react-refreshresolve? Confirm/@react-refreshreturns 200 in the Network panel. - Is the file included? MDX,
.jswith JSX, and monorepo packages may be outside defaults. - Is the file excluded?
node_modules, workers, and non-React TSX files may be intentionally excluded. - Are exports stable? Fast Refresh expects component-boundary-compatible exports.
- Does SSR call
transformIndexHtml? If not, use@vitejs/plugin-react/preamble. - Is HMR disabled?
server.hmr = falsedisables refresh.
If build fails, first suspect Babel migration. If dev hot updates fail, start with preamble, runtime URL, and export boundaries.
Edge Cases And Pitfalls
Map each pitfall back to the plugin layer when reading the source. Preamble issues live in HTML/virtual-module handling. Babel issues live in plugin composition. MDX issues live in include/exclude filters. Export-boundary issues live in the refresh wrapper. That keeps debugging from collapsing into one giant transform search.
Self-check
- Why does v6 configure Vite's Oxc pipeline through
config()instead of directly calling Oxc from a hand-writtentransform()? - What condition makes
transformIndexHtmlpreamble injection work? Why do SSR projects often bypass it? - Why is
reactRefreshHostmostly a module federation option? - If you used Babel plugins in v5, why should you stop looking for
react({ babel })in v6?
// Design Vite config for a React + MDX project:
// 1. MDX files also need Fast Refresh
// 2. React Compiler should only process src/components/**
// 3. The SSR dev server does not call transformIndexHtml
// Write the plugin order and explain where the preamble belongs.