Skip to content

bun run: let env NODE_ENV=development override tsconfig jsx dev/prod - #36469

Open
robobun wants to merge 4 commits into
mainfrom
farm/45f165ff/bun-run-node-env-dev-jsx
Open

bun run: let env NODE_ENV=development override tsconfig jsx dev/prod#36469
robobun wants to merge 4 commits into
mainfrom
farm/45f165ff/bun-run-node-env-dev-jsx

Conversation

@robobun

@robobun robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What

Since #34422 made tsconfig "jsx": "react-jsx" select the production automatic runtime, bun run applies env NODE_ENV to that selection asymmetrically:

tsconfig "react-jsxdev" + NODE_ENV=production   -> jsx-runtime       NODE_ENV wins
tsconfig "react-jsx"    + NODE_ENV=development  -> jsx-runtime       tsconfig wins (bug)

--define process.env.NODE_ENV='"development"' did force dev; only the env-var path was one-directional.

Repro

mkdir /tmp/j && cd /tmp/j
mkdir -p node_modules/shim
printf '{"name":"shim","type":"module","exports":{"./jsx-runtime":"./rt.js","./jsx-dev-runtime":"./dev.js"}}' > node_modules/shim/package.json
echo 'export const jsx=()=>console.log("prod");export const jsxs=jsx;export const Fragment=0;' > node_modules/shim/rt.js
echo 'export const jsxDEV=()=>console.log("dev");export const Fragment=0;' > node_modules/shim/dev.js
printf 'globalThis.a=<div/>;\n' > m.jsx
printf '{"compilerOptions":{"jsx":"react-jsx","jsxImportSource":"shim"}}' > tsconfig.json

NODE_ENV=development bun run m.jsx   # prints "prod", expected "dev"

Cause

configure_defines() samples is_production from the env loader directly (env_loader.is_production()) but derives is_development only from options.define.dots["NODE_ENV"]. bun run sets env.behavior = LoadAllWithoutInlining, which makes load_defines skip injecting env NODE_ENV into the define map, so is_development can never become true from the environment. --define goes into the define map regardless of behavior, which is why that path worked.

Fix

Sample is_development from the env loader alongside is_production. When the define map does carry a NODE_ENV value (via --define, or on the bun build path which does inject it), that value still takes precedence and now clears the opposite flag, so --define process.env.NODE_ENV='"production"' continues to beat env NODE_ENV=development.

This gives the precedence documented on Pragma::development (--define > NODE_ENV > tsconfig) for bun run in both directions.

Verification

Added a 2x4 matrix (NODE_ENV / BUN_ENV x both tsconfig values x both env values) plus two --define-over-env cases to test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts. 2 of the 10 new cases fail on main ("react-jsx" + {NODE_ENV,BUN_ENV}=development), all pass with the fix.

Existing suites green: bundler_jsx, jsx-production, esbuild/tsconfig, bundler_env, bundler_cjs2esm -t NodeEnv, bundler_bun.

Related

#36269 fixes the bun build (bundled) half of the dev/prod precedence and touches the same configure_defines block with a force_node_env == Unspecified guard and force_node_env = Production assignment; both read the same is_production/is_development locals so whichever lands second is a trivial rebase. #36209 and #36261 change the Arguments.rs --jsx-* default and are independent of this change.


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

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
bun test v1.4.0 (ad86bb4a2)

test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts:
48 |       cwd: String(dir),
49 |       stdout: "pipe",
50 |       stderr: "pipe",
51 |     });
52 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
53 |     expect({ stderr, stdout: stdout.trim(), exitCode }).toEqual({ stderr: "", stdout: expected, exitCode: 0 });
                                                             ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "stderr": "",
-   "stdout": "dev jsxDEV",
+   "stdout": "prod jsx",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts:53:57)
(fail) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + NODE_ENV=development -> dev jsxDEV [348.87ms]
48 |       cwd: String(dir),
49 |       stdout: "pipe",
50 |       stderr: "pipe",
51 |     });
52 |     const
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts:
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + NODE_ENV=development -> dev jsxDEV [18.86ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + NODE_ENV=production -> prod jsx [17.24ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + NODE_ENV=development -> dev jsxDEV [17.57ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + NODE_ENV=production -> prod jsx [16.63ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + BUN_ENV=production -> prod jsx [14.98ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + BUN_ENV=development -> dev jsxDEV [16.15ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + BUN_ENV=development -> dev jsxDEV [14.76ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + BUN_ENV=production -> prod jsx [16.31ms]
(pass) bun run: --define process.env.NODE_ENV over
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
bun test v1.4.0 (ad86bb4a2)

test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts:
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + NODE_ENV=development -> dev jsxDEV [313.06ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + NODE_ENV=production -> prod jsx [284.68ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + NODE_ENV=production -> prod jsx [281.06ms]
(pass) bun run: env NODE_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + NODE_ENV=development -> dev jsxDEV [296.63ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + BUN_ENV=development -> dev jsxDEV [304.59ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsx" + BUN_ENV=production -> prod jsx [301.25ms]
(pass) bun run: env BUN_ENV overrides tsconfig jsx dev/prod > tsconfig "react-jsxdev" + BUN_ENV=development -> dev jsxDEV [281
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ad86bb4a2e
  features     baseline

22 deps, 108 codegen, 1171 objects in 818ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [14.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [4.00ms]
[4/1234] gen ErrorCode+*.h
[5/1234] fetch picohttpparser
[picohttpparser] up to date
[6/1234] gen bindgenv2
[7/1234] fetch zlib
[zlib] up to date
[8/1234] gen .bind.ts → GeneratedBindings.cpp
[9/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1234] fetch tinycc
[tinycc] up to date
[11/1234] subst deps/zlib/zlib.h
[12/1234] subst deps/zlib/zconf.h
[13/1234] fetch nodejs (prebuilt)
[nodejs] up to date
[14/123
... (truncated)
diff hotspot
src/bundler/transpiler.rs                          |  4 +-
 .../transpiler/jsx-tsconfig-react-jsx.test.ts      | 51 ++++++++++++++++++++++
 2 files changed, 54 insertions(+), 1 deletion(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                    reads  edits  tests
src/bundler/transpiler.rs                                   5      5      0
test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts      3      5      0

configure_defines() sampled is_production from the env loader directly but
derived is_development only from define.dots, which bun run never populates
(its LoadAllWithoutInlining env behavior skips the NODE_ENV define
injection). The result was one-directional: env NODE_ENV=production forced
the production JSX runtime over tsconfig react-jsxdev, but env
NODE_ENV=development could not force the dev runtime over tsconfig
react-jsx.

Sample is_development from the env loader the same way is_production is.
When the define map does carry a NODE_ENV (via --define, or on the
bun build path), it still takes precedence and now clears the opposite flag
so --define=production continues to beat env=development.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The transpiler now derives development mode from loaded NODE_ENV values and keeps explicit production/development defines mutually exclusive. Tests cover environment and --define process.env.NODE_ENV precedence for automatic JSX runtime selection.

NODE_ENV-driven JSX selection

Layer / File(s) Summary
Derive development and production flags
src/bundler/transpiler.rs
configure_defines uses loaded NODE_ENV values and synchronizes production/development flags for explicit defines.
Validate JSX runtime precedence
test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
Tests cover NODE_ENV, BUN_ENV, tsconfig JSX settings, and explicit process.env.NODE_ENV definitions.

Possibly related PRs

  • oven-sh/bun#35557: Also changes automatic JSX runtime import and selection behavior.
  • oven-sh/bun#35954: Changes environment define injection relevant to NODE_ENV resolution.
  • oven-sh/bun#36269: Adjusts development/production JSX selection and related tests.

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 is concise and accurately describes the main bun run JSX dev/prod precedence change.
Description check ✅ Passed The description is complete with what changed, repro, cause, fix, and verification, though its headings differ from the template.

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

@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with NODE_ENV=development bun run m.jsx against tsconfig "jsx": "react-jsx" (prints prod on main, dev with this change). Fail-before/pass-after verified via git stash -- src/ && bun bd test ...: 2 of 12 cases in test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts fail without the fix.

Review feedback addressed in c86d65a (test title placeholder) and 19d4918 (dropped comment). CI on the first push was unrelated pre-existing/flaky lanes only; the new push re-runs it.

@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 - Also fixes NODE_ENV overriding tsconfig jsx dev/prod in configure_defines() in src/bundler/transpiler.rs; PR bun run: let env NODE_ENV=development override tsconfig jsx dev/prod #36469 supersedes it by handling both the development and production directions

🤖 Generated with Claude Code

Comment thread src/bundler/transpiler.rs
Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts
The 3-placeholder format string bound the trailing -> %s to the tsconfig
value instead of the expected output.
Comment thread src/bundler/transpiler.rs Outdated

@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 `@src/bundler/transpiler.rs`:
- Around line 579-588: Update the explicit NODE_ENV handling in the transpiler
mode-selection logic to reset is_production and is_development before matching
the defined value, so values other than "production" or "development" do not
inherit environment-derived flags. Preserve the existing matching behavior and
add coverage for an environment production value overridden by an explicit
"test" define, ensuring tsconfig controls JSX mode.

In `@test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts`:
- Line 36: Update the comment in the test case to remove the
implementation-history text “was: tsconfig won -> prod”, retaining only the
durable “env wins” invariant.
🪄 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: c4a58587-7260-4e49-8da8-df08b7f9fbd9

📥 Commits

Reviewing files that changed from the base of the PR and between c08f665 and c86d65a.

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

Comment thread src/bundler/transpiler.rs
Comment thread test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts 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.

LGTM — all prior feedback (test title placeholder, comment noise, history note) is addressed and the bug hunter found nothing new this run.

What was reviewed:

  • is_development is seeded from env_loader.get_node_env(), symmetric with the existing is_production line; both use the same BUN_ENVNODE_ENV fallback so the test's BUN_ENV axis is exercised.
  • Mutual-exclusion clears in the define-map branch prevent both flags being true; from env alone they can't both be true since they read the same var.
  • The --define="test" + env leak-through is now symmetric with pre-existing prod-side behavior (line 544); scoped out to #36269 as discussed.
  • Tests: concurrent, drain pipes via Promise.all, assert combined {stderr, stdout, exitCode}, bunEnv spread with both env vars cleared before setting the one under test.
Extended reasoning...

Overview

Two-file change: src/bundler/transpiler.rs (configure_defines()) seeds is_development from the env loader alongside the existing is_production seed, and adds mutual-exclusion clears when the define-map override fires. test/bundler/transpiler/jsx-tsconfig-react-jsx.test.ts gains a 2×4 env-override matrix (NODE_ENV/BUN_ENV × both tsconfig jsx values × both env values) plus two --define-over-env precedence cases.

Security risks

None. This adjusts which JSX runtime import path (jsx-runtime vs jsx-dev-runtime) the transpiler selects based on already-trusted env/define inputs. No untrusted-input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Medium — it's a user-facing transpiler behavior change, but a narrowly targeted symmetry fix. Before this PR, env NODE_ENV=production overrode tsconfig react-jsxdev but env NODE_ENV=development did not override tsconfig react-jsx; this makes both directions behave the same. The mechanism (read env loader directly, since LoadAllWithoutInlining skips injecting env into define.dots) is well-explained and matches what I read at transpiler.rs:544-600 and env_loader.rs:176-182.

Other factors

  • All three prior review threads (my two nits + comment-cop) are resolved by commits c86d65a, 19d4918, ad86bb4; CodeRabbit's "reset both flags" suggestion was withdrawn after the author correctly showed it would regress the very case being fixed (had_explicit_node_env is true for env-only, and define.dots has no entry under LoadAllWithoutInlining).
  • Fail-before/pass-after was verified by the author (2 of 12 cases fail on main).
  • The PR flags overlap with #36269 (same block, force_node_env guard) as a trivial rebase — worth noting for whoever lands second, but not a blocker here.
  • Tests follow harness conventions: tempDir, test.concurrent.each, {...bunEnv, NODE_ENV: undefined, BUN_ENV: undefined, [envVar]: value}, concurrent pipe drain, combined-object assertion.

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