From 3beee4f50ad4e52a5364c75555c6791fb82f0b98 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 16:46:50 +0100 Subject: [PATCH 1/2] 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', From 45499cc00cd8592e96fe4bb5d529d151f2d04e27 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sat, 12 Sep 2026 16:52:11 +0100 Subject: [PATCH 2/2] fix(react): drop the uninvalidated tsconfig cache The TsconfigCache added in this PR is created once per plugin instance and never invalidated, so editing a tsconfig during dev keeps resolving the old jsxImportSource: Vite sends a full-reload, but later responses still import the previous source. Reported by @doctor8296 on Vite 8.2.2 / Rolldown 1.2.6. Clearing the cache before every resolveTsconfig call fixes the staleness but leaves the cache doing nothing. The cache parameter is optional and upstream resolves without one, so this removes it instead. Adds a regression test that reuses a single plugin instance across a tsconfig change. It fails with the cache present, resolving @emotion/react where react is expected. --- packages/plugin-react/src/index.ts | 8 ++-- .../plugin-react/tests/reactCompiler.test.ts | 47 ++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index c800119b4..b7c0cb27e 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -304,7 +304,6 @@ interface TsconfigResolution { ) => { tsconfig: { compilerOptions?: { jsxImportSource?: string } } } | null - TsconfigCache: new () => { clear(): void } } let tsconfigResolutionPromise: @@ -337,7 +336,6 @@ 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' @@ -399,11 +397,11 @@ function createReactCompilerPlugin( const api = await loadTsconfigResolution() if (api) { resolveTsconfig = api.resolveTsconfig - tsconfigCache = new api.TsconfigCache() } } - importSource = resolveTsconfig?.(filename, tsconfigCache)?.tsconfig - .compilerOptions?.jsxImportSource + 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. diff --git a/packages/plugin-react/tests/reactCompiler.test.ts b/packages/plugin-react/tests/reactCompiler.test.ts index 22ff4ef3e..5c51b6103 100644 --- a/packages/plugin-react/tests/reactCompiler.test.ts +++ b/packages/plugin-react/tests/reactCompiler.test.ts @@ -243,6 +243,50 @@ describe('compiler option', () => { ) 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
Hello
+} +`, + ) + 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( @@ -292,12 +336,13 @@ async function jsxRuntimeImports( styled: path.join(root, 'styled/component.tsx'), plain: path.join(root, 'plain/component.tsx'), }, + plugin: ReturnType = pluginReact(options), ) { const buildOutput = await build({ root, configFile: false, logLevel: 'silent', - plugins: [pluginReact(options)], + plugins: [plugin], build: { write: false, minify: false,