From 3beee4f50ad4e52a5364c75555c6791fb82f0b98 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 16:46:50 +0100 Subject: [PATCH] fix(react): respect tsconfig jsxImportSource (fix #1448) The native compiler lowered JSX before Vite could apply per-file tsconfig inference. Use the same tsconfig resolver as Vite's oxc transform so compiler: true preserves jsxImportSource. --- packages/plugin-react/CHANGELOG.md | 4 + packages/plugin-react/src/index.ts | 58 ++++++- .../plugin-react/tests/reactCompiler.test.ts | 155 +++++++++++++++++- 3 files changed, 213 insertions(+), 4 deletions(-) diff --git a/packages/plugin-react/CHANGELOG.md b/packages/plugin-react/CHANGELOG.md index a44a758ed..5f61ff9d0 100644 --- a/packages/plugin-react/CHANGELOG.md +++ b/packages/plugin-react/CHANGELOG.md @@ -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 diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index 778858962..c800119b4 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -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, @@ -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 + | 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 { + 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, @@ -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' @@ -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, diff --git a/packages/plugin-react/tests/reactCompiler.test.ts b/packages/plugin-react/tests/reactCompiler.test.ts index 69ee703bd..22ff4ef3e 100644 --- a/packages/plugin-react/tests/reactCompiler.test.ts +++ b/packages/plugin-react/tests/reactCompiler.test.ts @@ -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, @@ -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
Hello
+} +` + 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
Hello
+} +`, + ) + + const result = await jsxRuntimeImports( + root, + { compiler: true }, + { + app: path.join(root, 'src/component.tsx'), + }, + ) + expect(result.app).toBe('@emotion/react') + }) }) async function transformWithBuildConfig( @@ -205,6 +285,79 @@ async function transformWithBuildConfig( } } +async function jsxRuntimeImports( + root: string, + options: Options, + entries: Record = { + 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',