Skip to content

bundler: keep the cwd tsconfig.json out of the per-file JSX base - #38805

Open
robobun wants to merge 6 commits into
mainfrom
farm/8f06493a/jsx-tsconfig-base
Open

robobun wants to merge 6 commits into
mainfrom
farm/8f06493a/jsx-tsconfig-base

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • For a file governed by a nested tsconfig.json, the fields that tsconfig leaves unset (jsxImportSource, jsxFactory, jsxFragmentFactory, "jsx") are filled in from the tsconfig.json of the directory the build is started from. With tsconfig.json = {"jsx":"react-jsx","jsxImportSource":"preact"} and app/tsconfig.json = {"jsx":"react-jsx"}, bun build app/m.tsx imports preact/jsx-runtime, while Bun.build({entrypoints:["./app/m.tsx"]}) and cd app && bun build m.tsx import from react. tsc applies only app/tsconfig.json to app/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.
  • The same leak affects 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=bun build.
  • Cause: the resolver builds each file's pragma as enclosing_tsconfig.merge_jsx(opts.jsx) (src/resolver/resolver.rs, finalize_result), so resolver.opts.jsx is the base every file's own tsconfig is merged over. configure_linker() / run_env_loader() (src/bundler/transpiler.rs) merge the cwd tsconfig into options.jsx, which is what bun run parses every file with, and sync_resolver_opts() plus Transpiler::for_worker (the client transpiler used for HTML entry points) then re-projected resolver.opts from options, copying that merged pragma into the base. A default Bun.build() was unaffected only because its env behavior skips the run_env_loader merge and its explicit jsx config skips the configure_linker copy.
  • Two more defects the first fix exposes, both pre-existing:
    • TSConfigJSON::merge_jsx copied import_source but not package_name, which is where the key-after-spread createElement fallback is imported from. Bun.build(), Bun.Transpiler({ tsconfig }), extends chains and CLI builds with a bunfig.toml already emitted jsx from shim/jsx-runtime next to createElement from react; the plain CLI was right only because of the wholesale copy above.
    • With packages: "external", imports that match a tsconfig paths alias return early from resolve_and_auto_install (the bug: bun test --changed ignores tsconfig paths aliases, silently skipping affected tests #29590 block) without finalize_result, so those files got the bare opts.jsx and 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::init passes options.jsx as from_api built it (CLI flags / bunfig / Bun.build({ jsx }), no tsconfig read yet); sync_resolver_opts() keeps the pragma the resolver holds; for_worker copies the parent resolver's. options.jsx itself is untouched, so bun run and bundled-mode dev/prod selection (see below) behave exactly as before.
  • merge_jsx copies package_name with import_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's createElement fallback (shown by the test below).
  • The bug: bun test --changed ignores tsconfig paths aliases, silently skipping affected tests #29590 early return builds its Result and runs it through finalize_result like 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).
  • Why this layer: the per-file merge already exists in the resolver, and its base is what every other in-flight change in this area assumes it to be. The production flips that do belong in the base are already written to resolver.opts directly (set_production in configure_defines / run_env_loader, --production in build_command.rs), so they still apply. Removing the cwd fold-in from options.jsx instead would be the smaller end state, but on main options.jsx still 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_development in bundle_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 same configure_linker -> sync_resolver_opts path and are covered by the same change.
  • Landing order: this is the base the cluster builds on. Transpile each file with the tsconfig.json nearest to it at runtime #38719 currently merges the nearest tsconfig over options.jsx at runtime and asserts bun run == bun build for exactly this layout; on top of this PR it should merge over resolver.opts.jsx instead (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() then merge_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.
  • Visible consequences of the base no longer carrying the cwd tsconfig, beyond the fields above, are confined to --no-bundle (bundled builds overwrite every file's dev/prod bit from options.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's react-jsx dev/prod choice (the result Bun.build() already gives), and NODE_ENV=production is no longer discarded by a cwd tsconfig that does not set "jsx" (the result bun run already gives). The sites where bundle_v2 takes a file's pragma from options.jsx directly (onResolve results, files: entries, and the plugin fallback in enqueue_parse_task, which bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269 rewrites) are unchanged.
  • Tests: test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts, new describe block. Six layouts (each leaking field, a file outside every tsconfig, the createElement fallback, and a --jsx-import-source / jsx.importSource case showing the flags remain the base) run through bun build, bun build --no-bundle, bun build --env=inline, Bun.build() and Bun.build({ env: "inline" }); plus the --target=bun index.html build, the --packages=external / packages: "external" alias case (cwd-governed and nested file side by side), and the two --no-bundle dev/prod cases. 25 of the 37 tests fail on the unfixed build. Reverting the merge_jsx line fails the createElement cases, reverting the for_worker line fails the HTML case, and reverting the resolver hunk fails the alias case with both files on the bare base.
  • Also run: bundler_jsx, bundler_edgecase (has the bug: bun test --changed ignores tsconfig paths aliases, 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_bundler is clean.

Background

  • JSX pragma (jsx::Pragma, src/options_types/jsx.rs): the settings the parser transpiles JSX with: runtime (classic h(...) calls vs automatic jsx() imports), factory/fragment names for classic, the package the automatic runtime is imported from (import_source for x/jsx-runtime, package_name for the createElement fallback), and the dev/prod bit.
  • A build has two pragmas. options.jsx is transpiler-wide: bun run parses every file with it, and configure_linker / run_env_loader fold the cwd tsconfig into it for that purpose. resolver.opts.jsx is only the base of the per-file merge; bun build (ParseTask) and bun build --no-bundle (Transpiler::transform) parse with the merged per-file result, Result::jsx.
  • TSConfigJSON::merge_jsx copies only the fields a tsconfig actually sets (tracked in jsx_flags), which is why whatever is in the base shows through for the fields it leaves unset.
  • resolver_bundle_options_subset projects the bundler's BundleOptions into the smaller struct the resolver crate reads. It runs at Transpiler::init, again from sync_resolver_opts() once an entry point (bun build, Bun.build, bake) has finished configuring options, and from for_worker, which derives the extra transpiler a server-side build uses for browser-target files such as HTML entry points.
  • finalize_result is 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 --changed ignores tsconfig paths aliases, silently skipping affected tests #29590 early return, which this PR routes through it as well.
Repro on 1.4.0
mkdir -p t/app && cd t && mkdir node_modules
echo '{"compilerOptions":{"jsx":"react-jsx","jsxImportSource":"preact"}}' > tsconfig.json
echo '{"compilerOptions":{"jsx":"react-jsx"}}' > app/tsconfig.json
echo 'export const el = <div />;' > app/m.tsx

bun build app/m.tsx --external '*'                                # preact/jsx-runtime
bun build --no-bundle app/m.tsx                                   # preact/jsx-runtime
bun build --env=inline --jsx-import-source=foo app/m.tsx --external '*'   # preact/jsx-runtime (flag ignored)
(cd app && bun build m.tsx --external '*')                        # react/jsx-runtime
bun -e 'const r = await Bun.build({entrypoints:["./app/m.tsx"], external:["*"]}); console.log(await r.outputs[0].text())'
                                                                  # react/jsx-dev-runtime
bun -e 'const r = await Bun.build({entrypoints:["./app/m.tsx"], external:["*"], env:"inline"}); console.log(await r.outputs[0].text())'
                                                                  # preact/jsx-runtime

createElement fallback, tsconfig.json = {"compilerOptions":{"jsxImportSource":"preact"}}, k.tsx = declare const p: any; export const el = <div {...p} key="k" />;:

bun build k.tsx --external '*'     # import { createElement } from "preact";   (wholesale copy)
bun -e 'const r = await Bun.build({entrypoints:["./k.tsx"], external:["*"]}); console.log(await r.outputs[0].text())'
                                   # import { createElement } from "react";    (merge_jsx)

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.jsx and app/b.jsx each containing JSX, main.js importing @lib/a and @app/b:

bun build --packages=external main.js        # both files: cwd-src
bun -e 'const r = await Bun.build({entrypoints:["./main.js"], packages:"external"}); console.log(await r.outputs[0].text())'
                                             # both files: react

After this PR: app/m.tsx imports from react in every command above except the --jsx-import-source=foo one (foo); the fallback case imports from preact on both lines everywhere; lib/a.jsx gets cwd-src and app/b.jsx gets nested-src in both the CLI and the API.

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.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 225cf0f3-de54-4af5-85b1-3340e5c03a81

📥 Commits

Reviewing files that changed from the base of the PR and between a5c86ae and e3e75ff.

📒 Files selected for processing (5)
  • src/bundler/transpiler.rs
  • src/resolver/options.rs
  • src/resolver/resolver.rs
  • src/resolver/tsconfig_json.rs
  • test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:45 AM PT - Aug 15th, 2026

✅ @robobun, your commit e3e75ff3a0ec42e4eebda3f60237b7a78ef04c82 passed in Build #97750! 🎉


🧪   To try this PR locally:

bunx bun-pr 38805

That installs a local version of the PR into your bun-38805 executable, so you can run:

bun-38805 --bun

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 and on main with the layout in the PR description (cwd tsconfig.json setting jsxImportSource, app/tsconfig.json setting only "jsx"): bun build app/m.tsx, bun build --no-bundle, --env=inline, Bun.build({ env: "inline" }) and the HTML entry point of a --target=bun build all pick up the cwd tsconfig's settings for app/m.tsx, a default Bun.build() does not. Self-review turned up one more path with the same problem, packages: "external" plus a tsconfig paths alias (reproduced the same way, in both the CLI and Bun.build()); it is fixed here as well.

Fix is in this PR. test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts: 25 of the 37 tests in the new block fail on the unfixed build, all pass with it.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread src/bundler/transpiler.rs Outdated
Comment thread src/bundler/transpiler.rs Outdated
Comment thread src/resolver/options.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/bundler/transpiler.rs Outdated
Comment thread src/bundler/transpiler.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_subset call sites updated consistently; Transpiler::init passes the pre-tsconfig options.jsx, sync_resolver_opts and for_worker preserve the resolver's existing base.
  • The new finalize_result call in the #29590 block matches the pattern at the other call site (restore extension_order on both paths, flush debug logs on success only).
  • merge_jsx now copies package_name alongside import_source; checked against TSConfigJSON::parse, which sets both together when jsxImportSource is present.
  • Tests use tempDir/bunEnv, drain both pipes concurrently, and cover the CLI/API/--no-bundle/--env=inline/HTML/packages=external matrix.
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant