Skip to content

bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod - #36269

Open
robobun wants to merge 14 commits into
mainfrom
farm/be9b2a1d/bundler-jsx-tsconfig-cli-flag
Open

robobun wants to merge 14 commits into
mainfrom
farm/be9b2a1d/bundler-jsx-tsconfig-cli-flag

Conversation

@robobun

@robobun robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

What

bun build (bundled mode) with a tsconfig that sets "jsx": "react-jsx" or "react-jsxdev" loses that dev/prod selection as soon as any --jsx-* CLI flag is passed. The same applies to Bun.build({ jsx: {...} }) where the jsx option does not set development. The --no-bundle path and bun run read resolve_result.jsx per file and were already correct.

Repro

mkdir /tmp/t && cd /tmp/t
echo '{"compilerOptions":{"jsx":"react-jsxdev","jsxImportSource":"preact"}}' > tsconfig.json
echo 'export const a = <div />;' > a.jsx

bun build --external 'preact*' a.jsx
# import { jsxDEV } from "preact/jsx-dev-runtime";     ok

bun build --external 'preact*' --jsx-runtime=automatic a.jsx
# import { jsx } from "preact/jsx-runtime";            WRONG: tsconfig said dev

bun build --external 'preact*' --jsx-import-source=preact a.jsx
# import { jsx } from "preact/jsx-runtime";            WRONG: same

Cause

ParseTask::init copies the per-file resolve_result.jsx (which the resolver already merged from the nearest tsconfig) into task.jsx. Immediately afterwards, bundle_v2 overwrote task.jsx.development with the global options.jsx.development when force_node_env == Unspecified. options.jsx normally tracks the root tsconfig (via configure_linker_with_auto_jsx), so the overwrite was a no-op by default, but any --jsx-* flag makes transform_options.jsx Some, skips that tsconfig copy, and the per-file value was discarded.

Fix

  • bundle_v2.rs: on Unspecified, leave task.jsx.development alone; it is already the resolver's tsconfig-merged value. The four match blocks and the enqueue_parse_task plugin-fallback sibling (which wholesale-cloned options.jsx over the per-file value) now call a shared ForceNodeEnv::jsx_development_override() -> Option<bool> helper.
  • configure_defines: an explicit NODE_ENV=production (env or --define) now sets force_node_env = Production, mirroring the existing Development arm. The whole block is guarded on force_node_env == Unspecified so a caller-set value (HTMLBundle dev, bake) is not clobbered by ambient NODE_ENV.
  • build_command: --production sets force_node_env = Production alongside the existing jsx.development = false.
  • bake (mod.rs, bake_body.rs): set force_node_env from mode so the dev server keeps emitting jsx-dev-runtime with a "jsx": "react-jsx" tsconfig and stays dev under ambient NODE_ENV=production.
  • JSBundler: when jsx.development or a dev/prod-carrying jsx.runtime is explicitly passed, record it in a new jsx_development_explicit field and apply it to force_node_env after configure_defines() and the development export-condition check, so the explicit API value still overrides a conflicting tsconfig without disturbing options.production or condition resolution.

Precedence in bundled mode is now:

--production / explicit NODE_ENV / explicit jsx.development  >  tsconfig "jsx"  >  default (dev)

--jsx-import-source, --jsx-fragment, --jsx-factory, and --jsx-runtime=automatic no longer participate in dev/prod selection at all.

Behavior change

bun build with env NODE_ENV=production and tsconfig "jsx": "react-jsxdev" now emits the production runtime (was development). This is symmetric with the existing NODE_ENV=development handling (which already overrode tsconfig via force_node_env = Development) and matches what Bun.build({ define: { "process.env.NODE_ENV": '"production"' } }) already did per the #3768 test matrix.

Verification

New tests in test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts:

  • bun build m.jsx --external shim* with each of --jsx-runtime=automatic, --jsx-import-source=shim, --jsx-fragment=Fragment, --jsx-factory=h against both "react-jsx" and "react-jsxdev" tsconfigs (entry point + imported file), plus a --production guard.
  • Bun.build({ jsx: {...} }) with explicit development: true/false and runtime: 'react-jsx'/'react-jsxdev'/'automatic' against a conflicting tsconfig.

5 of 17 cases fail on main, all 17 pass with the fix.

jsx/sideEffectsTrueTsconfigAutomatic snapshot updated: it has tsconfig "react-jsx" and was encoding the dev runtime the old clobber produced; its assertion (no /* @__PURE__ */) still holds.

Existing suites green: bundler_jsx, esbuild/tsconfig, jsx-production, bundler_env, bun-build-api.

Related

#36209 fixes the bun run (non-bundled) half of --jsx-* flipping dev/prod by defaulting Arguments.rs to development: true. That change and this one are independent; with only #36209 applied the repro above flips which tsconfig value is discarded instead of fixing it.

#31651 adds the same two force_node_env = Production lines in configure_defines, but its premise and second test case predate #34422 (on current main "react-jsx" already selects the production runtime without NODE_ENV). This PR supersedes that change.

#33855 also sets force_node_env = Production for --production (for a different bug, ambient NODE_ENV leaking into the define). The two-line overlap in build_command.rs is trivially resolvable whichever lands first.


[review] gate passed · iteration 0 · 10 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_jsx.test.ts test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
bun test v1.4.0 (e7f8d3fe2)

test/bundler/bundler_jsx.test.ts:
(pass) bundler > jsx/AutomaticDev [1037.15ms]
(pass) bundler > jsx/AutomaticProd [715.37ms]
(todo) bundler > jsx/AutomaticFragmentDev
(todo) bundler > jsx/AutomaticFragmentProd
(pass) bundler > jsx/AutomaticFragmentNamedImportDev [649.19ms]
(pass) bundler > jsx/AutomaticFragmentNamedImportProd [620.61ms]
(pass) bundler > jsx/AutomaticLocalShadowDev [660.73ms]
(pass) bundler > jsx/AutomaticLocalShadowProd [658.94ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersProd [363.13ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersDev [310.92ms]
(pass) bundler > jsx/ClassicDev [687.85ms]
(pass) bundler > jsx/ClassicProd [653.31ms]
(pass) bundler > jsx/ClassicPragmaDev [622.19ms]
(pass) bundler > jsx/ClassicPragmaProd [638.04ms]
(todo) bundler > jsx/PragmaMultipleDev
(todo) bundler > jsx/PragmaMultipleProd
(pass) bundler > jsx/FactoryDev [666.00ms]
(pass) bundler > jsx/Facto
... (truncated)

release without fix: 4 skipped
bun test v1.4.0-canary.1 (18686dac5)

test/bundler/bundler_jsx.test.ts:
(pass) bundler > jsx/AutomaticDev [38.43ms]
(pass) bundler > jsx/AutomaticProd [17.46ms]
(todo) bundler > jsx/AutomaticFragmentDev
(todo) bundler > jsx/AutomaticFragmentProd
(pass) bundler > jsx/AutomaticFragmentNamedImportDev [16.17ms]
(pass) bundler > jsx/AutomaticFragmentNamedImportProd [15.64ms]
(pass) bundler > jsx/AutomaticLocalShadowDev [16.49ms]
(pass) bundler > jsx/AutomaticLocalShadowProd [14.59ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersProd [8.47ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersDev [6.54ms]
(pass) bundler > jsx/ClassicDev [16.12ms]
(pass) bundler > jsx/ClassicProd [15.06ms]
(pass) bundler > jsx/ClassicPragmaDev [14.38ms]
(pass) bundler > jsx/ClassicPragmaProd [13.97ms]
(todo) bundler > jsx/PragmaMultipleDev
(todo) bundler > jsx/PragmaMultipleProd
(pass) bundler > jsx/FactoryDev [15.97ms]
(pass) bundler > jsx/FactoryProd [15.92ms]
(pass) bundler > jsx/FactoryImportDev [14.08ms]
(pass) bundler > jsx/FactoryImportProd [14.02ms]
(pass) bundler > jsx/FactoryImportExplicitReactDefaultDev [7.02ms]
(pass) bundler > jsx/FactoryImportExplicitReact
... (truncated)
passes on PR (with fix)
ASAN with fix: 4 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_jsx.test.ts test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
bun test v1.4.0 (e7f8d3fe2)

test/bundler/bundler_jsx.test.ts:
(pass) bundler > jsx/AutomaticDev [1025.09ms]
(pass) bundler > jsx/AutomaticProd [760.82ms]
(todo) bundler > jsx/AutomaticFragmentDev
(todo) bundler > jsx/AutomaticFragmentProd
(pass) bundler > jsx/AutomaticFragmentNamedImportDev [673.05ms]
(pass) bundler > jsx/AutomaticFragmentNamedImportProd [795.46ms]
(pass) bundler > jsx/AutomaticLocalShadowDev [838.06ms]
(pass) bundler > jsx/AutomaticLocalShadowProd [819.21ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersProd [365.14ms]
(pass) bundler > jsx/AutomaticLocalShadowMinifyIdentifiersDev [348.71ms]
(pass) bundler > jsx/ClassicDev [658.46ms]
(pass) bundler > jsx/ClassicProd [690.29ms]
(pass) bundler > jsx/ClassicPragmaDev [666.08ms]
(pass) bundler > jsx/ClassicPragmaProd [655.27ms]
(todo) bundler > jsx/PragmaMultipleDev
(todo) bundler > jsx/PragmaMultipleProd
(pass) bundler > jsx/FactoryDev [711.82ms]
(pass) bundler > jsx/Facto
... (truncated)

release with fix: 4 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 740ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v
... (truncated)
diff hotspot
src/bundler/bundle_v2.rs                           |  99 ++++++++++-----
 src/bundler/transpiler.rs                          |  20 +--
 src/options_types/bundle_enums.rs                  |  11 ++
 src/runtime/api/JSBundler.rs                       |   4 +
 src/runtime/api/js_bundle_completion_task.rs       |   8 ++
 src/runtime/bake/bake_body.rs                      |   7 ++
 src/runtime/bake/mod.rs                            |   7 ++
 src/runtime/cli/build_command.rs                   |   2 +
 test/bundler/bundler_jsx.test.ts                   |  18 +--
 .../transpiler/jsx-tsconfig-react-jsx.test.ts      | 140 +++++++++++++++++++++
 10 files changed, 265 insertions(+), 51 deletions(-)

gate history · 4 passed · 0 rejected · iteration 0

evidence per changed file
file                                                    reads  edits  tests
src/bundler/bundle_v2.rs                                   15     21      0
src/bundler/transpiler.rs                                   6      2      0
src/options_types/bundle_enums.rs                           3      3      0
src/runtime/api/JSBundler.rs                                2      7      0
src/runtime/api/js_bundle_completion_task.rs                4      2      0
src/runtime/bake/bake_body.rs                               1      1      0
src/runtime/bake/mod.rs                                     1      1      0
src/runtime/cli/build_command.rs                            1      1      0
test/bundler/bundler_jsx.test.ts                            2      1      0
test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts      5      6      0

bun build (bundled) with a tsconfig that sets "jsx": "react-jsx" or
"react-jsxdev" lost its dev/prod selection as soon as any --jsx-* CLI flag
(or a Bun.build jsx option) was passed: bundle_v2 overwrote the per-file
tsconfig-merged jsx.development with the global options.jsx.development
whenever force_node_env was Unspecified. The --no-bundle path reads
resolve_result.jsx directly and was already correct.

Leave task.jsx.development alone on Unspecified (it is the resolver's
tsconfig-merged value). To keep --production and an explicit
NODE_ENV=production overriding tsconfig like before, have those paths set
force_node_env=Production, mirroring the existing Development handling.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

JSX runtime selection

Layer / File(s) Summary
Explicit JSX override propagation
src/options_types/bundle_enums.rs, src/runtime/api/JSBundler.rs, src/runtime/api/js_bundle_completion_task.rs
Explicit JSX development settings are recorded and translated into force_node_env and transpiler JSX configuration.
Build-mode environment enforcement
src/runtime/bake/..., src/runtime/cli/build_command.rs, src/bundler/transpiler.rs
Development and production modes propagate force_node_env to transpilers and resolvers, while explicit values are preserved.
BundleV2 JSX task selection
src/bundler/bundle_v2.rs
BundleV2 applies optional environment overrides across parse and resolution tasks and merges enclosing tsconfig JSX settings for file paths.
JSX runtime regression coverage
test/bundler/bundler_jsx.test.ts, test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
Tests cover tsconfig-driven runtime selection, explicit JSX overrides, production forcing, CLI flags, and plugin-resolved disk paths.

Possibly related PRs

  • oven-sh/bun#36209: Both changes cover preserving tsconfig-selected JSX development or production runtime behavior across CLI and bundler configuration.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main bundled JSX precedence fix and matches the changeset.
Description check ✅ Passed The description covers the problem, cause, fix, verification, and related context, satisfying the template well.

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

@robobun

robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:40 PM PT - Jul 28th, 2026

⏳ @robobun, your commit e7f8d3f is still building in Build #84695, but has 2 failures so far (All Failures):

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(transpiler): force production JSX runtime when NODE_ENV=production #31651 - Adds the same force_node_env = Production fix in transpiler.rs; bundler: stop --jsx-* flags from overriding tsconfig jsx dev/prod #36269 is a superset that also fixes the bundle_v2.rs match blocks and build_command.rs

🤖 Generated with Claude Code

Comment thread src/options_types/bundle_enums.rs Outdated
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Re the duplicate flag: #31651 adds the same two force_node_env = Production lines in configure_defines, but its premise and second test case predate #34422 (on current main "react-jsx" already selects the production runtime without NODE_ENV). This PR supersedes that change and additionally fixes the per-file tsconfig clobber in bundle_v2.rs, which is the actual bug here.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bundler/bundle_v2.rs:2649-2655 — Bake's dev server relies on the removed Unspecified => t.options.jsx.development fallback: bake/mod.rs:301 and bake_body.rs:1248 set out.options.jsx.development = mode == Mode::Development after configure_defines() and never set force_node_env, so that write is now dead for every disk-resolved module. With a stock tsconfig.json "jsx": "react-jsx" (or ambient NODE_ENV=production via the new transpiler.rs:598-599), the bake dev server now serves react/jsx-runtime while React Fast Refresh (mod.rs:240) is enabled. Bake should set out.options.force_node_env / resolver.opts.force_node_env from mode alongside those two lines, mirroring what this PR adds in build_command.rs:464-465.

    Extended reasoning...

    What changed

    The four bundle_v2.rs sites used to do Unspecified => t.options.jsx.development, reading the bundler-global options.jsx.development at parse-task creation time. This PR changes them to Unspecified => {}, keeping whatever resolve_result.jsx.development the resolver produced. That is correct for bun build / Bun.build, which this PR audits — but bake's Framework::init_transpiler was another consumer of the removed fallback and is not updated here.

    At src/runtime/bake/mod.rs:297-301 (mirrored at src/runtime/bake/bake_body.rs:1243-1248):

    out.sync_resolver_opts();
    out.configure_linker();
    out.configure_defines()?;
    out.options.jsx.development = mode == Mode::Development;

    Bake never touches force_node_env. Line 301/1248 was load-bearing only via the removed Unspecified arm; after this PR nothing reads it for disk-resolved modules.

    Trigger (a): tsconfig "jsx": "react-jsx" — no env needed

    This is the default tsc --init / create-react-app / Vite React config.

    1. Bake creates the transpiler with TransformOptions::default() (mod.rs:214-219). No NODE_ENV/BUN_ENV in env, no --define, so in configure_defines() had_explicit_node_env stays false and force_node_env remains Unspecified.
    2. The resolver builds resolve_result.jsx per file via TSConfigJSON::merge_jsx. RUNTIME_MAP[b"react-jsx"].development = Some(false) sets JsxField::Development (tsconfig_json.rs:467-474), so merge_jsx writes out.development = false (tsconfig_json.rs:245-247) for every source file under that tsconfig — regardless of resolver.opts.jsx.development, so the later sync_resolver_opts() at mod.rs:348 does not help.
    3. DevServer drives BundleV2 (DevServer.rs:3303). At enqueue_entry_item (bundle_v2.rs:2649-2655) and resolve_import_records (bundle_v2.rs:6422-6427), force_node_env == Unspecified → {} → task.jsx.development stays false.
    4. Before this PR: the same arm read bundler.options.jsx.development, i.e. bake's line-301 true → react/jsx-dev-runtime. After this PR: → react/jsx-runtime.

    Trigger (b): ambient NODE_ENV=production

    1. Bake's env.behavior defaults to disable, whose arm in run_env_loader calls env.load_process() (transpiler.rs:785-786), so ambient process NODE_ENV=production makes is_production true at transpiler.rs:544.
    2. The new lines at transpiler.rs:598-599 set options.force_node_env = Production.
    3. Bake then writes options.jsx.development = true (line 301) but leaves force_node_env = Production.
    4. All four modified bundle_v2 sites now match Production => task.jsx.development = false. Before this PR force_node_env was never Production here (line 595 only called set_production(true)), so the Unspecified arm read bake's true.

    Note that guarding transpiler.rs:598-599 on force_node_env == Unspecified (a plausible fix for the HTMLBundle case) does not help trigger (b) — bake's force_node_env is genuinely Unspecified before configure_defines() runs, so the guarded write would still fire — and does nothing for trigger (a), which never reaches those lines.

    Impact

    The bake dev server (mode == Mode::Development) enables react_fast_refresh at mod.rs:240-242 and hot_module_reloading at mod.rs:236, but now emits the production JSX runtime (jsx/jsxs, no source-location args) instead of jsxDEV. React's error overlay and Fast Refresh source locations are degraded/broken on the standard React tsconfig with no user action. test/bake/dev/react-spa.test.ts has no tsconfig so would not catch trigger (a), and the PR's green-suite list does not include any bake suite.

    Fix

    Bake should set force_node_env alongside jsx.development, exactly as this PR does for --production in build_command.rs:464-465:

    out.configure_defines()?;
    out.options.jsx.development = mode == Mode::Development;
    out.options.force_node_env = match mode {
        Mode::Development => options::ForceNodeEnv::Development,
        _ => options::ForceNodeEnv::Production,
    };
    out.resolver.opts.force_node_env = out.options.force_node_env;

    at both bake/mod.rs:301 and bake_body.rs:1248. This makes bake's explicit mode authoritative under the new precedence (force_node_env > tsconfig), covers both triggers, and is symmetric with the CLI change already in this PR. Per REVIEW.md ("Before changing anything shared, enumerate every consumer" / "fix the whole class in the same PR"), the four bundle_v2 sites and configure_defines are shared across build_command, JSBundler, HTMLBundle, and bake — bake is a sibling caller of the same class and belongs in this PR.

  • 🔴 src/bundler/bundle_v2.rs:6421-6427 — There's a fifth sibling site this PR misses: enqueue_parse_task at bundle_v2.rs:3478 does task.jsx = self.transpiler_for_target(known_target).options.jsx.clone() right after ParseTask::init set task.jsx = resolve_result.jsx, so it still clobbers the tsconfig-merged value and never consults force_node_env. That path is hit via run_resolver (:2414) from the onResolve-plugin NoMatch fallback (:4514), so with tsconfig "react-jsxdev" + any --jsx-* flag + any onResolve plugin, the entry (via fixed :2649) now emits jsx-dev-runtime while every imported .jsx (via :3478) emits jsx-runtime — a mixed dev/prod bundle where before this PR both paths at least agreed. Drop the :3478 overwrite and apply the same force_node_env match as the four fixed sites (enqueue_parse_task2 at :3540 has the same .clone() shape).

    Extended reasoning...

    What the bug is

    The PR fixes four bundle_v2 sites where task.jsx.development was overwritten with the global options.jsx.development on ForceNodeEnv::Unspecified, but there is a fifth sibling that was not touched. enqueue_parse_task at bundle_v2.rs:3478 does:

    task.jsx = self.transpiler_for_target(known_target).options.jsx.clone();

    This runs immediately after ParseTask::init(&resolve_result, ..) (line 3470) has already set task.jsx = resolve_result.jsx.clone() (ParseTask.rs:262), so it wholesale-overwrites the resolver's tsconfig-merged JSX pragma — including .development — with the global CLI/API default. It also does not consult force_node_env at all.

    Code path that triggers it

    enqueue_parse_task is reached from run_resolver (bundle_v2.rs:2414 disk path, :2172 file-map path). run_resolver is invoked at bundle_v2.rs:4514, inside the ResolveValue::NoMatch arm of the onResolve-plugin fallback, for non-entry-point imports in the file namespace. Entry points in that same arm go through enqueue_entry_item (:4499), which is one of the four sites this PR fixed.

    Without a plugin, the very same non-entry import goes through the site this PR fixes at :6421 (resolve_import_records). So the plugin-fallback path and the no-plugin path for the same import record now diverge on .development.

    Why the fix doesn't cover it

    The PR's four changed sites now leave task.jsx.development alone on Unspecified, so the tsconfig value survives. Line 3478 predates and sits outside those changes; it still assigns the whole options.jsx struct, and there is no force_node_env match around it. The PR's own comment at :6425 — "resolve_result.jsx is already tsconfig-merged; keep it" — describes exactly what :3478 fails to do. The new tests only exercise the no-plugin path (b.jsx resolved via :6421), so this is not caught.

    Step-by-step proof (concrete divergence introduced by this PR)

    Setup, no NODE_ENV, no --define:

    • tsconfig.json: {"compilerOptions":{"jsx":"react-jsxdev","jsxImportSource":"shim"}}
    • entry.jsx imports ./b.jsx; both contain JSX.
    • CLI: bun build entry.jsx --external shim* --jsx-import-source=shim
    • Any onResolve plugin registered (even one that always returns undefined).

    Trace:

    1. --jsx-import-source=shim makes transform_options.jsx = Some(...) in Arguments.rs, so configure_linker_with_auto_jsx skips the tsconfig copy and transpiler.options.jsx.development stays at the CLI default (false). force_node_env = Unspecified.
    2. Entry point entry.jsx → enqueue_entry_item (bundle_v2.rs:2649, fixed by this PR). Unspecified arm now no-ops, so task.jsx.development keeps the resolver's true (from tsconfig react-jsxdev) → parser emits import { jsxDEV } from "shim/jsx-dev-runtime".
    3. Import ./b.jsx → onResolve plugin runs → returns undefined → ResolveValue::NoMatch at :4484 → non-entry, file namespace → run_resolver at :4514 → resolver returns resolve_result.jsx.development = true → enqueue_parse_task at :2414 → ParseTask::init sets task.jsx.development = true → line 3478 overwrites task.jsx = options.jsx.clone() → task.jsx.development = false → parser emits import { jsx } from "shim/jsx-runtime".
    4. Bundle output contains both "shim/jsx-dev-runtime" (entry) and "shim/jsx-runtime" (import), i.e. jsxDEV(...) and jsx(...) calls in the same bundle.

    Before this PR, step 2's Unspecified arm also read options.jsx.development = false, so both files emitted shim/jsx-runtime — wrong per the PR's stated goal, but internally consistent. After this PR the two paths disagree, which is a new user-visible inconsistency (and can break at runtime if the shim's dev and prod runtimes are not both present).

    Fix

    Drop the wholesale overwrite at :3478 — ParseTask::init already wrote resolve_result.jsx — and apply the same match force_node_env { Development => true, Production => false, Unspecified => {} } as the four fixed sites. enqueue_parse_task2 at :3540-3553 has the same options.jsx.clone() shape and, to the extent it has a resolve result available, should get the same treatment.

    Per REVIEW.md "Fix the whole class in the same PR — grep for every sibling site sharing the pattern": enqueue_parse_task is the direct plugin-path sibling of the site fixed at :6421, and merging without it leaves the plugin path both still-broken and now inconsistent with the non-plugin path.

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/transpiler.rs Outdated
robobun added 4 commits July 28, 2026 23:42
force_node_env also gates set_production and the development export
condition; setting it from jsx: { development: false } meant
NODE_ENV=production stopped setting options.production. Record the
explicit jsx value in a dedicated Config field and apply it to
force_node_env only after configure_defines and the condition check.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bundler/bundle_v2.rs:6413-6416 — enqueue_parse_task (bundle_v2.rs:3474) is an unfixed 5th sibling: it does task.jsx = self.transpiler_for_target(known_target).options.jsx.clone(), discarding the tsconfig-merged resolve_result.jsx that ParseTask::init just copied, and applies no jsx_development_override(). It is reached with a real disk resolve_result via on_resolve → run_resolver (:4510 → :2414) when an onResolve filter matched but the callback returned undefined. Before this PR that site and the four fixed sites all wrote the same global options.jsx.development on Unspecified; now :6413 keeps the per-file tsconfig value while :3474 still writes the global, so Bun.build + tsconfig "react-jsx" + a pass-through onResolve plugin emits both react/jsx-runtime and react/jsx-dev-runtime in one bundle depending on whether the specifier matched the plugin's filter. Preserve resolve_result.jsx for the disk-resolve caller (:2414) and apply jsx_development_override() like the other four sites.

    Extended reasoning...

    What the bug is

    The PR replaces the four force_node_env match blocks in bundle_v2.rs (at :2541, :2651, :6047, :6413) with jsx_development_override(), so Unspecified now leaves task.jsx.development at the resolver's tsconfig-merged value. But there is a fifth sibling that never had a force_node_env match block and so was missed by the mechanical replacement: enqueue_parse_task at bundle_v2.rs:3474 does

    let task: &mut ParseTask = self.arena_create(ParseTask::init(resolve_result, ..));
    task.loader = Some(loader);
    task.jsx = self.transpiler_for_target(known_target).options.jsx.clone();

    ParseTask::init (ParseTask.rs:262) has already set jsx: resolve_result.jsx.clone() — the per-file tsconfig-merged value. Line 3474 then unconditionally overwrites the whole struct with the global transpiler.options.jsx, and applies no force_node_env.jsx_development_override() afterwards.

    The specific code path that reaches it with a disk resolve_result

    enqueue_parse_task is called from run_resolver at bundle_v2.rs:2414 with a real disk resolve_result produced by transpiler.resolver.resolve(source_dir, specifier, kind) — i.e. carrying the tsconfig-merged JSX. run_resolver is reached via on_resolve at bundle_v2.rs:4510: when an onResolve plugin's filter matched the import specifier (enqueue_on_resolve_plugin_if_needed at :5927 returned true and the loop continued), the plugin callback returned undefined (ResolveValue::NoMatch), namespace is "file" and kind ≠ EntryPointBuild. This is the standard fallback for pass-through onResolve plugins. So two files under the same tsconfig can go through :6413 (direct disk resolve) or :3474 (plugin-fallback disk resolve) based solely on whether their specifier matches an onResolve filter regex.

    Why this is a NEW inconsistency introduced by this PR

    Before this PR, on Unspecified the four fixed sites overwrote task.jsx.development with transpiler.options.jsx.development, and enqueue_parse_task overwrote the whole task.jsx with transpiler.options.jsx.clone() — both produced the same development value, so every file in a bundle agreed (consistently wrong, which is what this PR is fixing). After this PR, site :6413 keeps resolve_result.jsx.development on Unspecified, but :3474 still writes the global transpiler.options.jsx.development. When those differ (see below), files under the same tsconfig now emit different JSX runtimes in one bundle.

    Why the global differs from tsconfig for Bun.build

    js_bundle_completion_task.rs:1047 unconditionally sets transform_options.jsx = Some(config.jsx.clone()), so configure_linker_with_auto_jsx at transpiler.rs:700 (if self.options.transform_options.jsx.is_none()) skips the root-tsconfig copy and transpiler.options.jsx.development stays at config.jsx's default (true, JSBundler.rs:191). With no NODE_ENV/BUN_ENV and no process.env.NODE_ENV define, configure_defines leaves force_node_env = Unspecified.

    Step-by-step proof

    Setup:

    // tsconfig.json
    {"compilerOptions":{"jsx":"react-jsx"}}
    // a.jsx
    import "./b.jsx"; import "@x/c"; export const a = <div/>;
    // b.jsx / node_modules/@x/c/index.jsx: any JSX under the same tsconfig
    await Bun.build({
      entrypoints: ["a.jsx"],
      plugins: [{ name: "p", setup(b) { b.onResolve({ filter: /^@x\// }, () => undefined) } }],
    });

    No NODE_ENV, no define.

    step ./b.jsx (no filter match) @x/c (filter match, plugin returns undefined)
    import loop :5927 enqueue_on_resolve_plugin_if_needed → false → true, continue
    resolve path direct disk resolve → resolve_result.jsx.development = false (tsconfig react-jsx) on_resolve NoMatch (:4480) → run_resolver (:4510) → disk resolve → resolve_result.jsx.development = false
    jsx assignment :6413 resolve_task.jsx = resolve_result.jsx.clone() :3474 task.jsx = transpiler.options.jsx.clone() → development = true
    Unspecified handling jsx_development_override() → None → keep false (no override applied) → stays true
    emitted import react/jsx-runtime react/jsx-dev-runtime

    One bundle, one tsconfig, two JSX runtimes. Before this PR both files emitted react/jsx-dev-runtime (both took transpiler.options.jsx.development = true).

    Impact and fix

    Per REVIEW.md ("Fix the whole class in the same PR — grep for every sibling site sharing the pattern"), this is exactly the bug class the PR targets — per-file tsconfig jsx.development clobbered by the global in bundle_v2 — and leaving :3474 unfixed both misses part of the class and introduces a within-bundle divergence that did not exist before. Fix: for the disk-resolve caller (:2414), drop the unconditional task.jsx = transpiler.options.jsx.clone() (keep ParseTask::init's resolve_result.jsx) and apply force_node_env.jsx_development_override() afterwards, matching sites :2541/:2651/:6413. The other caller of enqueue_parse_task (:2172, virtual/file-map path with a synthetic resolve_result) may still want the global overwrite, so splitting behavior on the caller (or on whether resolve_result.jsx came from the resolver) is likely cleanest.

    Not a duplicate of the two prior comments — the JSBundler explicit-development demotion and the HTMLBundle force_node_env clobber both concern how force_node_env/options.jsx are set; this concerns a fifth bundle_v2 site that overwrites task.jsx and never consults force_node_env at all.

Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/runtime/api/js_bundle_completion_task.rs
Comment thread src/bundler/bundle_v2.rs
… options.jsx.development coupled

- on_resolve_async Success arm (:4664) now applies jsx_development_override()
- run_resolver FileMap path (:2172) seeds file_map_result.jsx from options.jsx
  so enqueue_parse_task keeps the user's importSource/factory/fragment for
  virtual files
- js_bundle_completion_task re-writes options.jsx.development alongside
  force_node_env so any reader that clones options.jsx without the override
  stays consistent

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bundler/bundle_v2.rs:6059-6066 — The open comment on js_bundle_completion_task.rs:978 flags bundle_v2.rs:4664 as the missed options.jsx.clone() site and proposes mirroring this hunk's jsx_development_override() pattern there — but that doesn't cover the PR's own headline scenario (no NODE_ENV, no jsx option, tsconfig "react-jsx", plugin onResolve → disk path): force_node_env is Unspecified so the override returns None, and jsx_development_explicit is None so the other proposed fix (writing options.jsx.development in that block) never runs either. Disk-resolved siblings now emit react/jsx-runtime (this PR's fix) while :4664 still clones options.jsx.development = true and emits react/jsx-dev-runtime — a mixed-runtime bundle newly introduced by removing the Unspecified overwrite. :4664 needs a structurally different fix than :6061 got: either look up the enclosing tsconfig for the plugin-returned path, or stop unconditionally setting transform_options.jsx = Some(...) in create_and_configure_transpiler when no jsx option was passed so configure_linker_with_auto_jsx seeds options.jsx from the root tsconfig and :4664's clone matches its siblings.

    Extended reasoning...

    What the bug is

    The still-open review comment on js_bundle_completion_task.rs:978 correctly identifies bundle_v2.rs:4664-4668 (the plugin onResolve → ResolveValue::Success arm) as the one options.jsx.clone() site the PR's sweep didn't touch, and offers two fixes: (a) also write transpiler.options.jsx.development = dev in the jsx_development_explicit block, or (b) apply force_node_env.jsx_development_override() after the clone at :4664, mirroring what this hunk does at :6061-6066. Both fixes work for that comment's trigger (ambient NODE_ENV=production + explicit jsx.development). This finding shows a broader trigger — the PR's own headline repro applied through a plugin — for which neither proposed fix works, so following the existing comment verbatim leaves :4664 diverging.

    The uncovered path

    Bun.build with no jsx option still hits create_and_configure_transpiler (js_bundle_completion_task.rs:~1047), which sets transform_options.jsx = Some(config.jsx.clone()) unconditionally. configure_bundler then writes transpiler.options.jsx.development = config.jsx.development = true (the Config::default() value at JSBundler.rs:192). configure_linker_with_auto_jsx (transpiler.rs:702) sees transform_options.jsx.is_some() and skips the root-tsconfig copy, so options.jsx.development stays true regardless of tsconfig. With no NODE_ENV/BUN_ENV and no define, configure_defines leaves force_node_env = Unspecified; with no jsx option, jsx_development_explicit = None and the block at :972-978 doesn't run.

    Step-by-step proof

    Environment: no NODE_ENV/BUN_ENV, tsconfig {"compilerOptions":{"jsx":"react-jsx"}}.

    await Bun.build({
      entrypoints: ['a.jsx'],           // a.jsx: import 'virt'; export const a = <div/>;
      external: ['react*'],
      plugins: [{ name: 'p', setup(b) {
        b.onResolve({filter:/^virt$/}, () => ({ path: cwd + '/v.jsx', namespace: 'file' }))
      }}],
    })
    step value
    config.jsx.development (JSBundler.rs:192, no user jsx option) true
    config.jsx_development_explicit None
    transform_options.jsx (create_and_configure_transpiler) Some(...) — unconditional
    configure_linker_with_auto_jsx (transpiler.rs:702) is_some() → skip tsconfig copy → options.jsx.development = true
    configure_defines (no NODE_ENV) force_node_env = Unspecified
    :972-978 (jsx_development_explicit = None) block does not run

    Now bundle:

    file path task.jsx.development emits
    a.jsx (disk-resolved) :2645 → task.jsx = resolve_result.jsx (tsconfig-merged, dev=false); Unspecified.jsx_development_override() = None → stays false false react/jsx-runtime ✓
    v.jsx (plugin-resolved) :4664 → jsx: options.jsx.clone() → dev=true; no override applied true react/jsx-dev-runtime ✗

    Two different JSX runtimes in one bundle. Before this PR, the removed Unspecified => options.jsx.development overwrite forced disk-resolved a.jsx to true too, so both were consistently react/jsx-dev-runtime — the divergence is newly introduced by this PR.

    Why neither of the existing comment's proposed fixes covers this

    • Fix (a) — "also write transpiler.options.jsx.development = dev in the jsx_development_explicit block": jsx_development_explicit is None here (the user passed no jsx option), so the block at :972-978 never runs. options.jsx.development stays true.
    • Fix (b) — "apply force_node_env.jsx_development_override() after the clone at :4664, like :6061-6066": force_node_env is Unspecified, so jsx_development_override() returns None and the cloned dev=true is kept.

    The :6061-6066 pattern (clone options.jsx + apply override) is sufficient there because :6061 is the FileMap path — no tsconfig applies to a virtual files: {…} entry anyway, so options.jsx is the correct fallback. At :4664 the plugin returned a real disk path whose enclosing tsconfig should apply, but the resolver never ran on it — there is no resolve_result.jsx to seed from, and options.jsx is not tsconfig-merged (see above).

    How to fix

    Two options; either restores consistency with the six sites this PR swept:

    1. Seed options.jsx from the root tsconfig for Bun.build: in create_and_configure_transpiler, only set transform_options.jsx = Some(...) when the user actually passed a jsx option (track a jsx_was_set bool alongside jsx_development_explicit). Then configure_linker_with_auto_jsx copies the root tsconfig into options.jsx, and :4664's clone matches disk-resolved siblings in the common single-tsconfig case (still not per-file, but no longer diverging).
    2. Look up the enclosing tsconfig at :4664: for namespace == "file", run the resolver's tsconfig lookup on the plugin-returned path and use tsconfig.merge_jsx(options.jsx) instead of the raw clone. This is the per-file-correct fix and matches what disk-resolved paths get via resolve_result.jsx.

    Per REVIEW.md "Fix the whole class in the same PR": :4664 is a sibling of the six sites this PR swept, and this is the PR's own headline repro (no env, no jsx option, tsconfig "react-jsx") applied through a plugin onResolve — a common shape for alias/redirect plugins.

robobun added 2 commits July 29, 2026 01:15
The onResolve Success arm builds a ParseTask without a resolver-produced
resolve_result, so clone options.jsx and then merge the enclosing
tsconfig for file-namespace paths the same way finalize_result does for
disk-resolved files. Keeps plugin-resolved and disk-resolved files in
the same bundle on the same JSX runtime when tsconfig carries the
dev/prod choice.
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

The additional finding re: plugin onResolve → disk path at bundle_v2.rs:4664 is addressed in 707310f / fc1984d: for namespace: "file" the bundler now looks up the enclosing tsconfig via resolver.read_dir_info_ignore_error(dir) and runs merge_jsx before applying jsx_development_override(), matching what finalize_result does for disk-resolved files. New parameterized test covers both react-jsx and react-jsxdev for an onResolve-returned disk path alongside a disk-resolved entry in the same bundle.

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts`:
- Around line 95-109: Update all four Bun subprocess invocations in
test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts at lines 95-109 and
121-132 to run “bd build”, and at lines 153-171 and 189-213 to run “bd -e”
instead of the direct Bun commands. Preserve the existing arguments and
assertions.
- Around line 72-76: Update the describe block comment for “bun build: --jsx-*
flags preserve tsconfig dev/prod” to scope the invariant only to JSX CLI flags
that do not set development mode. Remove the bundle_v2 implementation-history
explanation, and explicitly preserve --jsx-development as an override that takes
precedence over tsconfig.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 05dc061a-fd50-4f25-91d6-e09a36022f17

📥 Commits

Reviewing files that changed from the base of the PR and between e532ad9 and fc1984d.

📒 Files selected for processing (10)
  • src/bundler/bundle_v2.rs
  • src/bundler/transpiler.rs
  • src/options_types/bundle_enums.rs
  • src/runtime/api/JSBundler.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/bake/bake_body.rs
  • src/runtime/bake/mod.rs
  • src/runtime/cli/build_command.rs
  • test/bundler/bundler_jsx.test.ts
  • test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts

Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts Outdated
Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts Outdated
@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Diff is ready for review at e7f8d3f. All automated review threads are addressed and resolved.

CI on builds 84498 and 84695: the Rust build (including bun_bundler and bun_runtime) completed successfully on every lane. Remaining red:

  • build-bun on linux aarch64-musl/aarch64-android/aarch64/x64-musl: the sibling build-cpp step expired or errored on its first attempt (Buildkite agent, not a compile error; aarch64-musl build-cpp state is expired); the build-bun step then had nothing to link. This diff touches no C++ code.
  • Test failures are all [flaky] parallel-batch tests that passed alone on retry (socket-retention, spawn-streaming-stdout, 20875, 25432, numeric-header, s3, transpiler-cache), plus a worker-transfer-terminate-stress JSC assertion on debian-asan that is not touched by this diff and has been reported separately, and a Failed to create agent on debian-asan.

Locally: 19/19 in the new test block, plus bundler_jsx, esbuild/tsconfig, jsx-production, bundler_env, bundler_plugin, bun-build-api all green.

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