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
58 changes: 55 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,37 @@ export default function viteReact(opts: Options = {}): Plugin[] {
return plugins
}

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

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 +337,8 @@ function createReactCompilerPlugin(
): Plugin {
let jsxDevelopment = false
let compiler: typeof import('oxc-transform-react') | undefined
let tsconfigCache: { clear(): void } | undefined
let resolveTsconfig: TsconfigResolution['resolveTsconfig'] | undefined
const runtime =
reactCompilerOptions.target === '17' || reactCompilerOptions.target === '18'
? 'react-compiler-runtime'
Expand Down Expand Up @@ -357,12 +391,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
tsconfigCache = new api.TsconfigCache()
}
}
importSource = resolveTsconfig?.(filename, tsconfigCache)?.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
155 changes: 154 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,84 @@ 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')
})
})

async function transformWithBuildConfig(
Expand Down Expand Up @@ -205,6 +285,79 @@ 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'),
},
) {
const buildOutput = await build({
root,
configFile: false,
logLevel: 'silent',
plugins: [pluginReact(options)],
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