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
4 changes: 4 additions & 0 deletions packages/plugin-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

Add `refreshInclude` and `refreshExclude` options to skip fast refresh for certain files that won't run in the browser.

### Add `fastRefresh` option

Set `fastRefresh` to `false` to disable Fast Refresh while keeping HMR, for setups where another plugin already provides it, such as React Router in framework mode. This allows `react({ compiler: true, fastRefresh: false })` to be used next to `reactRouter()` so that JSX and the React Compiler are still handled by this plugin.

## 6.1.2 (2026-10-05)

### Fix HMR for compound components ([#1484](https://github.com/vitejs/vite-plugin-react/pull/1484))
Expand Down
17 changes: 17 additions & 0 deletions packages/plugin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ react({ reactRefreshHost: 'http://localhost:3000' })

Under the hood, this simply updates the React Fash Refresh runtime URL from `/@react-refresh` to `http://localhost:3000/@react-refresh` to ensure there is only one Refresh runtime across the whole application. Note that if you define `base` option in the host application, you need to include it in the option, like: `http://localhost:3000/{base}`.

### fastRefresh

Fast Refresh is enabled during development by default. Set `fastRefresh` to `false` when another plugin already provides it, so that the two do not conflict. This is the case for React Router in framework mode, whose Vite plugin ships its own Fast Refresh integration and fails with `Identifier 'RefreshRuntime' has already been declared` when both are active. The plugin then only configures JSX and, if enabled, runs the [React Compiler](#react-compiler), while the other plugin owns Fast Refresh. HMR itself is not affected.

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

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

## React Compiler

### Rust React Compiler
Expand Down Expand Up @@ -112,6 +127,8 @@ Set `logDiagnostics` to `true` to log recoverable React Compiler diagnostics thr
react({ compiler: { logDiagnostics: true } })
```

To use the compiler in a setup where another plugin provides Fast Refresh, such as React Router in framework mode, combine it with [`fastRefresh: false`](#fastrefresh).

### 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
19 changes: 17 additions & 2 deletions packages/plugin-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export interface Options {
* reactRefreshHost: 'http://localhost:3000'
*/
reactRefreshHost?: string
/**
* Set to `false` to disable Fast Refresh: the refresh transform, the
* runtime and the preamble are skipped while HMR stays enabled.
* Useful when another plugin already provides Fast Refresh, e.g. React
* Router in framework mode, so that this plugin can still handle JSX and
* the React Compiler next to it.
* @default true
*/
fastRefresh?: boolean
/**
* Enable React Compiler with its default options or configure it.
* This requires `oxc-transform-react` to be installed.
Expand Down Expand Up @@ -109,14 +118,20 @@ function viteReact(opts: Options = {}): Plugin[] {
command: 'serve' | 'build',
hmr: ServerOptions['hmr'],
) {
return isProduction || command === 'build' || hmr === false
return (
opts.fastRefresh === false ||
isProduction ||
command === 'build' ||
hmr === false
)
}

const viteBabel: Plugin = {
name: 'vite:react-babel',
enforce: 'pre',
config(_userConfig, { command }) {
const refresh = command === 'serve' && !opts.compiler
const refresh =
command === 'serve' && !opts.compiler && opts.fastRefresh !== false
if (opts.jsxRuntime === 'classic') {
return {
oxc: {
Expand Down
2 changes: 2 additions & 0 deletions playground/react-router/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.react-router
build
56 changes: 56 additions & 0 deletions playground/react-router/__tests__/react-router.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from 'vitest'
import {
browserErrors,
editFile,
isBuild,
isServe,
page,
testDir,
untilBrowserLogAfter,
viteServer,
viteTestUrl,
} from '~utils'

test('should render', async () => {
await untilBrowserLogAfter(() => page.goto(viteTestUrl), 'hydrated')
expect(await page.textContent('button')).toMatch('count is 0')
expect(await page.click('button'))
expect(await page.textContent('button')).toMatch('count is 1')
expect(browserErrors).toEqual([])
})

test.runIf(isServe)('should compile without Fast Refresh', async () => {
const result = await viteServer.environments.client.transformRequest(
'/app/routes/home.tsx',
)
const code = result!.code
expect(code).toMatch(/_c\(\d+\)/)
expect(code).toContain('jsx-dev-runtime')
// Fast Refresh is only set up by React Router
expect(code).not.toContain('/@react-refresh')
expect(code.match(/import RefreshRuntime from/g)).toEqual([
'import RefreshRuntime from',
])
expect(code).toContain('virtual:react-router/hmr-runtime')
})

test.runIf(isServe)('should preserve state on edit', async () => {
expect(await page.textContent('button')).toMatch('count is 1')
editFile('app/routes/home.tsx', (code) =>
code.replace('count is {count}', 'count is {count}!'),
)
await expect.poll(() => page.textContent('button')).toMatch('count is 1!')
expect(browserErrors).toEqual([])
})

test.runIf(isBuild)('should compile components', () => {
const assetsDir = path.join(testDir, 'build/client/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')
})
38 changes: 38 additions & 0 deletions playground/react-router/__tests__/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import path from 'node:path'
import {
createBuilder,
createServer,
preview,
type InlineConfig,
type ViteDevServer,
} from 'vite'
import { isBuild, rootDir, setViteUrl } from '~utils'

export let viteServer: ViteDevServer

// The React Router plugin requires a config file, which the default serve
// disables, so load it explicitly here.
export async function serve() {
const config: InlineConfig = {
root: rootDir,
configFile: path.join(rootDir, 'vite.config.ts'),
logLevel: 'silent',
}

if (isBuild) {
const builder = await createBuilder(config)
await builder.buildApp()
const server = await preview(config)
setViteUrl(server.resolvedUrls!.local[0].replace(/\/$/, ''))
return server
}

viteServer = await (
await createServer({
...config,
server: { watch: { usePolling: true, interval: 100 } },
})
).listen()
setViteUrl(viteServer.resolvedUrls!.local[0].replace(/\/$/, ''))
return viteServer
}
27 changes: 27 additions & 0 deletions playground/react-router/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}

export default function App() {
useEffect(() => {
console.log('hydrated')
}, [])

return <Outlet />
}
3 changes: 3 additions & 0 deletions playground/react-router/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { index, type RouteConfig } from '@react-router/dev/routes'

export default [index('routes/home.tsx')] satisfies RouteConfig
13 changes: 13 additions & 0 deletions playground/react-router/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useState } from 'react'

export default function Home() {
const [count, setCount] = useState(0)

return (
<>
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
</>
)
}
25 changes: 25 additions & 0 deletions playground/react-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "@vitejs/test-react-router",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"debug": "node --inspect-brk vite",
"preview": "vite preview"
},
"dependencies": {
"@react-router/node": "^8.5.0",
"isbot": "^5.1.40",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-router": "^8.5.0"
},
"devDependencies": {
"@react-router/dev": "^8.5.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "workspace:*",
"oxc-transform-react": "^0.155.0"
}
}
23 changes: 23 additions & 0 deletions playground/react-router/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"include": ["app"],
"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
}
}
10 changes: 10 additions & 0 deletions playground/react-router/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { reactRouter } from '@react-router/dev/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// React Router in framework mode provides its own Fast Refresh, so this
// plugin only handles JSX and the React Compiler next to it.
export default defineConfig({
server: { port: 8912 /* Should be unique */ },
plugins: [react({ compiler: true, fastRefresh: false }), reactRouter()],
})
Loading
Loading