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 @@ -2,6 +2,10 @@

## Unreleased

### Respect tsconfig `jsxImportSource` when using the native React Compiler ([#1448](https://github.com/vitejs/vite-plugin-react/issues/1448))

Enabling `compiler: true` no longer ignores per-file `jsxImportSource` from the nearest tsconfig. Inference now matches the non-compiler JSX path, and an explicit plugin `jsxImportSource` still overrides it.

## 6.1.1 (2026-08-28)

### Add `compiler.logDiagnostics` option
Expand Down
56 changes: 53 additions & 3 deletions packages/plugin-react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
exactRegex,
makeIdFiltersToMatchWithQuery,
Expand Down Expand Up @@ -296,6 +297,36 @@ export default function viteReact(opts: Options = {}): Plugin[] {
return plugins
}

interface TsconfigResolution {
resolveTsconfig: (
filename: string,
cache?: { clear(): void } | null,
) => {
tsconfig: { compilerOptions?: { jsxImportSource?: string } }
} | null
}

let tsconfigResolutionPromise:
| Promise<TsconfigResolution | undefined>
| undefined

// Same resolver Vite uses for oxc JSX (rolldown's resolveTsconfig). Loaded
// through Vite so we do not add a runtime dependency on rolldown internals.
function loadTsconfigResolution(): Promise<TsconfigResolution | undefined> {
tsconfigResolutionPromise ??= (async () => {
try {
const requireFromVite = createRequire(import.meta.resolve('vite'))
const experimentalPath = requireFromVite.resolve('rolldown/experimental')
return (await import(
pathToFileURL(experimentalPath).href
)) as TsconfigResolution
} catch {
return undefined
}
})()
return tsconfigResolutionPromise
}

function createReactCompilerPlugin(
{ logDiagnostics, ...reactCompilerOptions }: ReactCompilerPluginOptions,
include: NonNullable<Options['include']>,
Expand All @@ -305,6 +336,7 @@ function createReactCompilerPlugin(
): Plugin {
let jsxDevelopment = false
let compiler: typeof import('oxc-transform-react') | undefined
let resolveTsconfig: TsconfigResolution['resolveTsconfig'] | undefined
const runtime =
reactCompilerOptions.target === '17' || reactCompilerOptions.target === '18'
? 'react-compiler-runtime'
Expand Down Expand Up @@ -357,12 +389,30 @@ function createReactCompilerPlugin(
// The config hook is not called when the plugin is used with Rolldown directly.
const { transform } =
compiler ?? (await loadCompiler((message) => this.error(message)))
const filename = id.split('?')[0]!
let importSource = reactOptions.jsxImportSource
if (importSource === undefined) {
try {
if (!resolveTsconfig) {
const api = await loadTsconfigResolution()
if (api) {
resolveTsconfig = api.resolveTsconfig
}
}
importSource =
resolveTsconfig?.(filename)?.tsconfig.compilerOptions
?.jsxImportSource
} catch {
// Keep oxc-transform-react's default (`react`) when tsconfig
// lookup is unavailable, e.g. raw Rolldown without Vite.
}
}

const result = await transform(id.split('?')[0]!, code, {
const result = await transform(filename, code, {
jsx: {
runtime: reactOptions.jsxRuntime,
development: jsxDevelopment,
importSource: reactOptions.jsxImportSource,
importSource,
refresh: isClient && isFastRefreshEnabled(),
},
reactCompiler: shouldCompile ? reactCompilerOptions : false,
Expand Down
200 changes: 199 additions & 1 deletion packages/plugin-react/tests/reactCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { type Plugin, rolldown } from 'rolldown'
import { BuildEnvironment, type InlineConfig, resolveConfig } from 'vite'
import { BuildEnvironment, type InlineConfig, build, resolveConfig } from 'vite'
import { describe, expect, test } from 'vitest'
import pluginReact, {
type Options,
Expand Down Expand Up @@ -163,6 +165,128 @@ describe('compiler option', () => {
'Hooks must always be called in a consistent order',
)
})

test('infers per-file jsxImportSource from tsconfig when compiler is enabled', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'vite-plugin-react-1448-'))
const component = `export function Component() {
return <div>Hello</div>
}
`
await mkdir(path.join(root, 'styled'))
await mkdir(path.join(root, 'plain'))
await writeFile(
path.join(root, 'styled/tsconfig.json'),
JSON.stringify({
compilerOptions: {
jsx: 'react-jsx',
jsxImportSource: '@emotion/react',
},
}),
)
await writeFile(
path.join(root, 'plain/tsconfig.json'),
JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }),
)
await writeFile(path.join(root, 'styled/component.tsx'), component)
await writeFile(path.join(root, 'plain/component.tsx'), component)

const inferred = await jsxRuntimeImports(root, { compiler: true })
expect(inferred.styled).toBe('@emotion/react')
expect(inferred.plain).toBe('react')
expect(inferred.styledHasCompilerRuntime).toBe(true)
expect(inferred.plainHasCompilerRuntime).toBe(true)

const overridden = await jsxRuntimeImports(root, {
compiler: true,
jsxImportSource: '@emotion/react',
})
expect(overridden.styled).toBe('@emotion/react')
expect(overridden.plain).toBe('@emotion/react')
})

test('infers jsxImportSource from referenced tsconfig.app.json when compiler is enabled', async () => {
const root = await mkdtemp(
path.join(tmpdir(), 'vite-plugin-react-1448-app-'),
)
await mkdir(path.join(root, 'src'))
await writeFile(
path.join(root, 'tsconfig.json'),
JSON.stringify({
files: [],
references: [{ path: './tsconfig.app.json' }],
}),
)
await writeFile(
path.join(root, 'tsconfig.app.json'),
JSON.stringify({
include: ['src'],
compilerOptions: {
jsx: 'react-jsx',
jsxImportSource: '@emotion/react',
},
}),
)
await writeFile(
path.join(root, 'src/component.tsx'),
`export function Component() {
return <div>Hello</div>
}
`,
)

const result = await jsxRuntimeImports(
root,
{ compiler: true },
{
app: path.join(root, 'src/component.tsx'),
},
)
expect(result.app).toBe('@emotion/react')
})

test('re-resolves jsxImportSource after tsconfig changes in one plugin instance', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'vite-plugin-react-1450-'))
await mkdir(path.join(root, 'src'))
const tsconfig = path.join(root, 'tsconfig.json')
const write = (jsxImportSource?: string) =>
writeFile(
tsconfig,
JSON.stringify({
compilerOptions: {
jsx: 'react-jsx',
...(jsxImportSource && { jsxImportSource }),
},
}),
)
await write('@emotion/react')
await writeFile(
path.join(root, 'src/component.tsx'),
`export function Component() {
return <div>Hello</div>
}
`,
)
const entries = { app: path.join(root, 'src/component.tsx') }
// One instance across both builds, the way a dev server keeps one plugin alive.
const plugin = pluginReact({ compiler: true })

const before = await jsxRuntimeImports(
root,
{ compiler: true },
entries,
plugin,
)
expect(before.app).toBe('@emotion/react')

await write(undefined) // editing tsconfig mid-session, as in #1450
const after = await jsxRuntimeImports(
root,
{ compiler: true },
entries,
plugin,
)
expect(after.app).toBe('react')
})
})

async function transformWithBuildConfig(
Expand Down Expand Up @@ -205,6 +329,80 @@ async function transformWithBuildConfig(
}
}

async function jsxRuntimeImports(
root: string,
options: Options,
entries: Record<string, string> = {
styled: path.join(root, 'styled/component.tsx'),
plain: path.join(root, 'plain/component.tsx'),
},
plugin: ReturnType<typeof pluginReact> = pluginReact(options),
) {
const buildOutput = await build({
root,
configFile: false,
logLevel: 'silent',
plugins: [plugin],
build: {
write: false,
minify: false,
lib: {
entry: entries,
formats: ['es'],
},
rolldownOptions: {
external: /^(react|@emotion\/react)(\/|$)/,
output: { preserveModules: true },
},
},
})
const chunks = [buildOutput]
.flat()
.flatMap((output) => output.output)
.filter((output) => output.type === 'chunk')
const result: {
styled?: string
plain?: string
app?: string
styledHasCompilerRuntime?: boolean
plainHasCompilerRuntime?: boolean
} = {}
for (const entry of Object.keys(entries)) {
const chunk = chunks.find((output) => {
const fileName = output.fileName.replaceAll('\\', '/')
return (
fileName === `${entry}.js` ||
fileName === `${entry}.mjs` ||
fileName.endsWith(`/${entry}.js`) ||
fileName.endsWith(`/${entry}.mjs`)
)
})
if (!chunk) {
throw new Error(
`Missing entry: ${entry} (got ${chunks.map((c) => c.fileName).join(', ')})`,
)
}
const runtime = chunk.imports.find((specifier) =>
/\/jsx(?:-dev)?-runtime$/.test(specifier),
)
const importSource = runtime?.replace(/\/jsx(?:-dev)?-runtime$/, '')
if (entry === 'styled') {
result.styled = importSource
result.styledHasCompilerRuntime = chunk.imports.includes(
'react/compiler-runtime',
)
} else if (entry === 'plain') {
result.plain = importSource
result.plainHasCompilerRuntime = chunk.imports.includes(
'react/compiler-runtime',
)
} else if (entry === 'app') {
result.app = importSource
}
}
return result
}

async function getViteReactConfig(
options: Options,
command: 'serve' | 'build',
Expand Down