vite-mastery

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.

Vite 8.1Stable

Correct A Common Misread

@vitejs/plugin-react v6 is not "the old Babel transform moved behind Oxc". Its main path is:

  1. Configure Vite's Oxc pipeline for JSX runtime, import source, and Fast Refresh.
  2. Install the Fast Refresh preamble and runtime module through Vite/React shared helpers.
  3. Keep common React project options such as include, exclude, jsxImportSource, jsxRuntime, and reactRefreshHost.
  4. Remove the old direct babel option from v5; when you need Babel behavior, add @rolldown/plugin-babel explicitly.

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:

ts
react() -> [jsxTransformPlugin, refreshPlugin]

v6 is closer to this shape:

ts
export default function viteReact(options: Options = {}): Plugin[] {
  return [
    viteReactOxcConfig,
    viteRefreshWrapper,
    viteConfigPost,
    viteReactRefreshBundledDevMode,
    viteReactRefresh,
    virtualPreamblePlugin(...),
  ]
}

Each layer has a separate job:

LayerMain responsibility
Oxc config layerConfigure JSX and Refresh from jsxRuntime, jsxImportSource, include, and exclude
Refresh wrapperWrap React modules with Fast Refresh HMR logic in dev/client environments
Config post layerDisable refresh when server.hmr === false
Bundled dev mode preambleInject the preamble early in full bundle mode
Refresh runtime moduleExpose the /@react-refresh runtime
Preamble virtual pluginSupport 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:

ts
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:

  1. Oxc JSX transform: automatic/classic runtime, import source, and whether refresh is enabled.
  2. 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:

ts
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:

  1. Install the Refresh runtime when the page starts.
  2. Wrap transformed React modules with HMR accept logic.
  3. Invalidate HMR when exports no longer satisfy Fast Refresh rules.

Conceptually:

ts
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:

ts
import RefreshRuntime from "/@react-refresh"

RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true

For 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:

ts
import "@vitejs/plugin-react/preamble"

Or call transformIndexHtml in the SSR dev server:

ts
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:

ts
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:

ts
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 habitv6 approach
react({ babel: { plugins: [...] } })Move it to @rolldown/plugin-babel
Custom JSX import sourceKeep using react({ jsxImportSource: "..." })
Old classic JSX runtimeUse react({ jsxRuntime: "classic" })
MDX also needs RefreshAdjust include and ensure the MDX plugin runs before React
SSR does not call transformIndexHtmlImport @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:

  1. Is the preamble present? Search for __vite_plugin_react_preamble_installed__ in browser devtools.
  2. Does @react-refresh resolve? Confirm /@react-refresh returns 200 in the Network panel.
  3. Is the file included? MDX, .js with JSX, and monorepo packages may be outside defaults.
  4. Is the file excluded? node_modules, workers, and non-React TSX files may be intentionally excluded.
  5. Are exports stable? Fast Refresh expects component-boundary-compatible exports.
  6. Does SSR call transformIndexHtml? If not, use @vitejs/plugin-react/preamble.
  7. Is HMR disabled? server.hmr = false disables 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

  1. Why does v6 configure Vite's Oxc pipeline through config() instead of directly calling Oxc from a hand-written transform()?
  2. What condition makes transformIndexHtml preamble injection work? Why do SSR projects often bypass it?
  3. Why is reactRefreshHost mostly a module federation option?
  4. If you used Babel plugins in v5, why should you stop looking for react({ babel }) in v6?
ts
// 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.