From 2cb9f75bd322ec0012a6bb608ee94393504eb20e 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 5e7f89292..25a8dc5d7 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.2 (2026-10-05) ### Fix HMR for compound components ([#1484](https://github.com/vitejs/vite-plugin-react/pull/1484)) diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index ef18d78ff..928eab92d 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, @@ -297,6 +298,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, @@ -306,6 +338,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' @@ -371,12 +405,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: refreshEnabled, }, reactCompiler: !shouldCompile diff --git a/packages/plugin-react/tests/reactCompiler.test.ts b/packages/plugin-react/tests/reactCompiler.test.ts index dfe410e9c..d1cae1f8c 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 e253bf902e0778a8f072d612bb3801adaa4ecea0 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 928eab92d..6cb3315fa 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -305,7 +305,6 @@ interface TsconfigResolution { ) => { tsconfig: { compilerOptions?: { jsxImportSource?: string } } } | null - TsconfigCache: new () => { clear(): void } } let tsconfigResolutionPromise: @@ -338,7 +337,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' @@ -413,11 +411,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 d1cae1f8c..1f6283b12 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,