Repository navigation
Conversation
The resolver computes each file's JSX pragma by merging the file's enclosing tsconfig.json over resolver.opts.jsx. sync_resolver_opts() and Transpiler::for_worker re-projected that base from options.jsx after configure_linker() / run_env_loader() had merged the cwd tsconfig.json into it, so the cwd tsconfig's jsxImportSource, jsxFactory, jsxFragmentFactory and "jsx" filled in whatever a nested tsconfig.json left unset, and files outside of every tsconfig got the cwd's settings. This affected `bun build`, `bun build --no-bundle`, anything built with --env / env: "inline" (including Bun.build()), and HTML entry points of server-side builds, while a default Bun.build() was unaffected, so the same file bundled differently between the CLI and the JS API. resolver_bundle_options_subset() now takes the base pragma explicitly: Transpiler::init passes the freshly built user configuration, and the two re-projection sites carry the pragma the resolver already holds. TSConfigJSON::merge_jsx copies package_name along with import_source: the key-after-spread createElement fallback is imported from package_name, and until now the CLI only got that right because the cwd tsconfig was copied over the pragma wholesale.
|
Warning Review limit reached
Next review available in: 32 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 7:45 AM PT - Aug 15th, 2026
✅ @robobun, your commit e3e75ff3a0ec42e4eebda3f60237b7a78ef04c82 passed in 🧪 To try this PR locally: bunx bun-pr 38805That installs a local version of the PR into your bun-38805 --bun |
|
Status: reproduced on bun 1.4.0 and on main with the layout in the PR description (cwd Fix is in this PR. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes user-visible bun build output for projects with nested tsconfigs and is coordinated with several other in-flight PRs (#38050, #38070, #38719), a maintainer sign-off on the semantics would still be worthwhile.
Checked that all three resolver_bundle_options_subset call sites are updated and that Transpiler::init passes the pragma before any tsconfig read (line 1281 → 1289).
Confirmed set_production is still written directly to resolver.opts in run_env_loader, so NODE_ENV/--production still reach the base.
Verified Pragma: Default exists so mem::take in sync_resolver_opts is sound, and that tsconfig parsing sets package_name alongside import_source, so the new merge_jsx copy is symmetric.
Extended reasoning...
Overview
The PR stops the cwd's tsconfig.json from being used as the base JSX pragma that every file's own tsconfig is merged over during bun build. It threads an explicit jsx argument through resolver_bundle_options_subset (three call sites: Transpiler::init, sync_resolver_opts, for_worker) so the resolver's base pragma stays as from_api built it — CLI flags / bunfig / Bun.build({ jsx }) only. It also fixes TSConfigJSON::merge_jsx to copy package_name together with import_source, and adds ~250 lines of tests covering 5 build modes × 6 layouts plus HTML entry points and --no-bundle dev/prod cases.
Security risks
None. This is JSX transpilation configuration plumbing — no auth, crypto, network, or untrusted-input parsing changes.
Level of scrutiny
Medium-high. The Rust diff is small and mechanically straightforward, and I traced each call site: at init (transpiler.rs:1289) bundle_options is fresh from from_api with no tsconfig read yet; sync_resolver_opts runs after configure_linker/run_env_loader have merged the cwd tsconfig into options.jsx, so preserving the resolver's existing pragma via mem::take is correct; for_worker inherits the parent resolver's untainted base. The merge_jsx change matches how tsconfig parsing populates both package_name and import_source from jsxImportSource. However, this is a deliberate behavior change to what bun build emits — files under a nested tsconfig will now import from react instead of whatever the cwd tsconfig's jsxImportSource said, and files outside every tsconfig no longer inherit the cwd's settings at all. The PR argues convincingly that this matches tsc and existing Bun.build() behavior, but it is still a user-visible output change that a maintainer should ratify.
Other factors
The test coverage is thorough and the PR description explicitly enumerates which existing suites were re-run. The description also flags coordination with #38050 (duplicate package_name hunk), #38070/#36269 (bundled dev/prod handling), and #38719 (bun run per-file base) — someone with visibility into those should confirm the landing order. No prior human reviews on the timeline.
…ages=external The packages=external tsconfig "paths" early return (#29590) handed back the bare opts.jsx without going through finalize_result, so the files it found never got their own tsconfig.json merged in. That used to be masked for the cwd tsconfig because opts.jsx carried a copy of it; now that opts.jsx is the user configuration only, route these results through finalize_result like every other file on disk, which also gives them the per-file decorator flags, sideEffects data, module type and realpath the regular path computes.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes user-visible bundler output (which tsconfig each file's JSX pragma inherits from) across several build modes, widens the packages=external tsconfig-paths early return to run finalize_result (adding sideEffects/module-type/realpath processing that path previously skipped), and is explicitly coordinated with four other in-flight PRs, a maintainer look would still be worthwhile.
What was reviewed:
- All three
resolver_bundle_options_subsetcall sites updated consistently;Transpiler::initpasses the pre-tsconfigoptions.jsx,sync_resolver_optsandfor_workerpreserve the resolver's existing base. - The new
finalize_resultcall in the #29590 block matches the pattern at the other call site (restoreextension_orderon both paths, flush debug logs on success only). merge_jsxnow copiespackage_namealongsideimport_source; checked againstTSConfigJSON::parse, which sets both together whenjsxImportSourceis present.- Tests use
tempDir/bunEnv, drain both pipes concurrently, and cover the CLI/API/--no-bundle/--env=inline/HTML/packages=externalmatrix.
Extended reasoning...
Overview
This PR decouples the resolver's per-file JSX base (resolver.opts.jsx) from the transpiler-wide options.jsx so that a file governed by a nested tsconfig.json no longer inherits unset JSX fields from the cwd's tsconfig. It touches src/bundler/transpiler.rs (three call sites of resolver_bundle_options_subset, which gains a jsx parameter), src/resolver/tsconfig_json.rs (merge_jsx now copies package_name with import_source), src/resolver/resolver.rs (the #29590 packages=external + tsconfig paths early return now runs finalize_result), a doc comment on src/resolver/options.rs, and ~280 lines of new tests in test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts.
Security risks
None identified. The change is entirely about which tsconfig's JSX settings are applied to which source file during bundling; there is no auth, crypto, network, or untrusted-input parsing surface.
Level of scrutiny
High. This changes user-visible bundler output for multi-tsconfig projects: files that previously inherited the cwd tsconfig's jsxImportSource/jsxFactory/"jsx" will now use the default (or the CLI flag) instead. The description names two intentional --no-bundle behavior changes (dev/prod inheritance and NODE_ENV=production handling). The resolver hunk also broadens what the #29590 path computes — sideEffects, module type, decorator flags, and symlink realpath — which is described as correct but is a behavior change beyond JSX. And the PR is explicitly sequenced against #38719, #36269, #38050, and #36728, with rebase instructions depending on landing order. These are exactly the kinds of layering and cross-PR coordination decisions a maintainer should sign off on.
Other factors
The bug hunting system found nothing. The test coverage is thorough (37 tests across 6 fixture layouts × 5 build modes plus HTML, packages=external, and two --no-bundle dev/prod cases; 25 fail on the unfixed build per the description), tests follow harness conventions (tempDir, bunEnv with NODE_ENV unset, concurrent pipe drain, test.concurrent), and the description lists a broad set of existing suites re-run. The comment-cop bot's feedback has been addressed and all threads resolved. The diff itself is small and reads correctly, but the behavior surface it governs is large enough that this is not a mechanical change.
Problem
tsconfig.json, the fields that tsconfig leaves unset (jsxImportSource,jsxFactory,jsxFragmentFactory,"jsx") are filled in from thetsconfig.jsonof the directory the build is started from. Withtsconfig.json={"jsx":"react-jsx","jsxImportSource":"preact"}andapp/tsconfig.json={"jsx":"react-jsx"},bun build app/m.tsximportspreact/jsx-runtime, whileBun.build({entrypoints:["./app/m.tsx"]})andcd app && bun build m.tsximport fromreact. tsc applies onlyapp/tsconfig.jsontoapp/m.tsx. Found while working on Transpile each file with the tsconfig.json nearest to it at runtime #38719; no user report. Reproduces on 1.4.0 and main.bun build --no-bundle, anything built with--env=.../Bun.build({ env: "inline" })(there it also overrides an explicit--jsx-import-source), files outside of every tsconfig (they get the cwd's settings), and the scripts of an HTML entry point in a--target=bunbuild.enclosing_tsconfig.merge_jsx(opts.jsx)(src/resolver/resolver.rs,finalize_result), soresolver.opts.jsxis the base every file's own tsconfig is merged over.configure_linker()/run_env_loader()(src/bundler/transpiler.rs) merge the cwd tsconfig intooptions.jsx, which is whatbun runparses every file with, andsync_resolver_opts()plusTranspiler::for_worker(the client transpiler used for HTML entry points) then re-projectedresolver.optsfromoptions, copying that merged pragma into the base. A defaultBun.build()was unaffected only because its env behavior skips therun_env_loadermerge and its explicitjsxconfig skips theconfigure_linkercopy.TSConfigJSON::merge_jsxcopiedimport_sourcebut notpackage_name, which is where the key-after-spreadcreateElementfallback is imported from.Bun.build(),Bun.Transpiler({ tsconfig }),extendschains and CLI builds with a bunfig.toml already emittedjsxfromshim/jsx-runtimenext tocreateElementfromreact; the plain CLI was right only because of the wholesale copy above.packages: "external", imports that match a tsconfigpathsalias return early fromresolve_and_auto_install(the bug:bun test --changedignores tsconfigpathsaliases, silently skipping affected tests #29590 block) withoutfinalize_result, so those files got the bareopts.jsxand never their own tsconfig.Bun.build({ packages: "external" })already lost the tsconfig's JSX settings for such files; the CLI was right for cwd-governed files only through the same copy, so fixing the base alone would have regressed it there.Fix
resolver_bundle_options_subset()takes the base pragma as a parameter.Transpiler::initpassesoptions.jsxasfrom_apibuilt it (CLI flags / bunfig /Bun.build({ jsx }), no tsconfig read yet);sync_resolver_opts()keeps the pragma the resolver holds;for_workercopies the parent resolver's.options.jsxitself is untouched, sobun runand bundled-mode dev/prod selection (see below) behave exactly as before.merge_jsxcopiespackage_namewithimport_source. bun build: keep NODE_ENV=production when tsconfig.json does not set "jsx" #38050 and jsx: let bunfig.toml / --jsx-* override tsconfig.json #36728 carry this same line for their own reasons; whichever lands last drops its copy. It stays here because without it this PR regresses the CLI'screateElementfallback (shown by the test below).bun test --changedignores tsconfigpathsaliases, silently skipping affected tests #29590 early return builds itsResultand runs it throughfinalize_resultlike every other file on disk, which gives those files their tsconfig merge (and the per-file decorator flags, sideEffects data, module type and realpath that path also skipped).resolver.optsdirectly (set_productioninconfigure_defines/run_env_loader,--productioninbuild_command.rs), so they still apply. Removing the cwd fold-in fromoptions.jsxinstead would be the smaller end state, but on mainoptions.jsxstill has two readers that need it: the runtime parse sites (per-file in Transpile each file with the tsconfig.json nearest to it at runtime #38719) and bundled mode's per-build dev/prod choice (forced_jsx_developmentinbundle_v2, changed by bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269). Once those land, the fold-in can go and this base is what remains. Bake / the dev server configure their transpilers through the sameconfigure_linker->sync_resolver_optspath and are covered by the same change.options.jsxat runtime and assertsbun run==bun buildfor exactly this layout; on top of this PR it should merge overresolver.opts.jsxinstead (one line), after which its test pins both tools to the same base. bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269's new plugin-path merges (t.options.jsx.clone()thenmerge_jsx) want the same swap. If Transpile each file with the tsconfig.json nearest to it at runtime #38719 lands first, this PR's rebase makes that change.--no-bundle(bundled builds overwrite every file's dev/prod bit fromoptions.jsx.development/force_node_env, which bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269, bun build: keep NODE_ENV=production when tsconfig.json does not set "jsx" #38050 and bun build --no-bundle: apply force_node_env to the per-file JSX pragma #38070 deal with and this PR leaves alone): a nested tsconfig without"jsx"no longer inherits the cwd'sreact-jsxdev/prod choice (the resultBun.build()already gives), andNODE_ENV=productionis no longer discarded by a cwd tsconfig that does not set"jsx"(the resultbun runalready gives). The sites wherebundle_v2takes a file's pragma fromoptions.jsxdirectly (onResolveresults,files:entries, and the plugin fallback inenqueue_parse_task, which bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269 rewrites) are unchanged.test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts, newdescribeblock. Six layouts (each leaking field, a file outside every tsconfig, thecreateElementfallback, and a--jsx-import-source/jsx.importSourcecase showing the flags remain the base) run throughbun build,bun build --no-bundle,bun build --env=inline,Bun.build()andBun.build({ env: "inline" }); plus the--target=bun index.htmlbuild, the--packages=external/packages: "external"alias case (cwd-governed and nested file side by side), and the two--no-bundledev/prod cases. 25 of the 37 tests fail on the unfixed build. Reverting themerge_jsxline fails thecreateElementcases, reverting thefor_workerline fails the HTML case, and reverting the resolver hunk fails the alias case with both files on the bare base.bundler_jsx,bundler_edgecase(has the bug:bun test --changedignores tsconfigpathsaliases, silently skipping affected tests #29590 tests),bundler_npm,bundler_html,esbuild/tsconfig,bun-build-api,transpiler/transpiler.test.js,cli/run/tsconfig-override,bake/dev/react-spa,resolve/resolve,resolve/tsconfig-extends-leak,regression/issue/32492: pass.cargo clippy -p bun_resolver -p bun_bundleris clean.Background
jsx::Pragma,src/options_types/jsx.rs): the settings the parser transpiles JSX with: runtime (classich(...)calls vs automaticjsx()imports), factory/fragment names for classic, the package the automatic runtime is imported from (import_sourceforx/jsx-runtime,package_namefor thecreateElementfallback), and the dev/prod bit.options.jsxis transpiler-wide:bun runparses every file with it, andconfigure_linker/run_env_loaderfold the cwd tsconfig into it for that purpose.resolver.opts.jsxis only the base of the per-file merge;bun build(ParseTask) andbun build --no-bundle(Transpiler::transform) parse with the merged per-file result,Result::jsx.TSConfigJSON::merge_jsxcopies only the fields a tsconfig actually sets (tracked injsx_flags), which is why whatever is in the base shows through for the fields it leaves unset.resolver_bundle_options_subsetprojects the bundler'sBundleOptionsinto the smaller struct the resolver crate reads. It runs atTranspiler::init, again fromsync_resolver_opts()once an entry point (bun build,Bun.build, bake) has finished configuringoptions, and fromfor_worker, which derives the extra transpiler a server-side build uses for browser-target files such as HTML entry points.finalize_resultis the resolver's common post-processing for a file found on disk: package.json sideEffects, the enclosing tsconfig's JSX and decorator settings, module type, and symlink realpath. Every success path reaches it except the bug:bun test --changedignores tsconfigpathsaliases, silently skipping affected tests #29590 early return, which this PR routes through it as well.Repro on 1.4.0
createElement fallback,
tsconfig.json={"compilerOptions":{"jsxImportSource":"preact"}},k.tsx=declare const p: any; export const el = <div {...p} key="k" />;:paths alias under packages=external,
tsconfig.json={"compilerOptions":{"jsxImportSource":"cwd-src","paths":{"@lib/*":["./lib/*"],"@app/*":["./app/*"]}}},app/tsconfig.json={"compilerOptions":{"jsxImportSource":"nested-src"}},lib/a.jsxandapp/b.jsxeach containing JSX,main.jsimporting@lib/aand@app/b:After this PR:
app/m.tsximports fromreactin every command above except the--jsx-import-source=fooone (foo); the fallback case imports frompreacton both lines everywhere;lib/a.jsxgetscwd-srcandapp/b.jsxgetsnested-srcin both the CLI and the API.