Skip to content

bundler: detect module.exports=require() redirect through a constant-test if/else - #35611

Open
robobun wants to merge 3 commits into
mainfrom
farm/af8da350/bundler-cjs-redirect-through-if
Open

bundler: detect module.exports=require() redirect through a constant-test if/else#35611
robobun wants to merge 3 commits into
mainfrom
farm/af8da350/bundler-cjs-redirect-through-if

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Drops the unnecessary __commonJS wrapper around redirect files like react/index.js when bundling without --minify-syntax.

Fixes #12726.

Repro

// input.ts
import { renderToReadableStream } from "react-dom/server";
console.log([renderToReadableStream]);
bun add react@18.3.1 react-dom@18.3.1
bun build input.ts --target=bun | grep -c __commonJS

Before: 5 (one is the helper definition, four are wrappers). The fourth wrapper is around react/index.js:

var require_react = __commonJS(function(exports, module) {
  var react_development = __toESM(require_react_development());
  if (false) {} else {
    module.exports = react_development;
  }
});

After: 4 (three wrappers). require_react is gone and callers use require_react_development directly.

Cause

react/index.js is the standard NODE_ENV switch:

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

The define lookup folds the comparison to a boolean literal and DCE empties the dead branch regardless of minification, so after the visitor runs the file's only statement is if (false) {} else { module.exports = <expr> }. The module.exports = require(...) redirect detector in parse_entry.rs looked only for a bare SExpr, so the surviving SIf (which --minify-syntax would have flattened) blocked the redirect and the file kept its __commonJS wrapper.

Fix

When the single non-trivial statement is an SIf whose test is a boolean literal and whose dead branch is empty, unwrap it to the live branch's single statement before running the existing module.exports = ... match. The match itself and all the downstream redirect bookkeeping are unchanged.

This is guarded by the same conditions that already bound the redirect: the dead branch must have been emptied (which proves dead_code_elimination was on and the branch carried nothing that must survive), and the live branch must contain exactly one statement.

Verification

New cjs2esm/*NoMinify cases in test/bundler/bundler_cjs2esm.test.ts cover both NODE_ENV values, braced and unbraced if bodies, and the react package path where require() is first rewritten to an ESM namespace identifier. The *NoMinify cases fail on main and pass with this change; the rest of bundler_cjs2esm.test.ts and bundler_cjs.test.ts are unaffected. Output with --minify-syntax is byte-identical to before.

The jsx-production.tsx / jsx-dev.tsx fixture updates are required by this change: react/jsx-runtime ships the same NODE_ENV switch, so the redirect now collapses it into its production target and the bundle binds jsx directly instead of going through a jsx_runtime namespace object. The fixtures asserted the namespace variable name (jsx_runtime.jsx), an artifact of the wrapper this PR removes; they now assert the bundled runtime's file name plus the presence/absence of jsxDEV(, which holds in both shapes, and the runtime stdout assertions are unchanged.


[review] gate passed · iteration 6 · 4 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.0 (79f05d679)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [800.43ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [387.94ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [385.51ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [399.19ms]
(pass) bundler > cjs2esm/ExportsFunction [375.70ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [372.61ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [363.49ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [587.94ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [572.40ms]
cjs2esm check failed. expected 0 __commonJS helpers but found 2.
1561 |         const outfiletext = api.readFile(path.relative(root, outfile ?? outputPaths[0]));
1562 |         const regex = /\/\/\s+(.+?)\nvar\s+([a-zA-Z0-9_$]+)\s+=\s+__commonJS/g;
1563 |         const matches = [...outfiletext.matchAll(regex)].map(match => ("/" + matc
... (truncated)

release without fix: 6 FAILED
bun test v1.4.0-canary.1 (b58cd4685)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [26.37ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [12.38ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [13.54ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [13.10ms]
(pass) bundler > cjs2esm/ExportsFunction [10.23ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [9.74ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [9.68ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [14.88ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [19.73ms]
cjs2esm check failed. expected 0 __commonJS helpers but found 2.
1561 |         const outfiletext = api.readFile(path.relative(root, outfile ?? outputPaths[0]));
1562 |         const regex = /\/\/\s+(.+?)\nvar\s+([a-zA-Z0-9_$]+)\s+=\s+__commonJS/g;
1563 |         const matches = [...outfiletext.matchAll(regex)].map(match => ("/" + match[1]).replaceAll("\\", "/"));
1564 |         const expectedMatches = (cjs2esm === true ? [] : (cjs2esm.unhandled ?? [])).map(a => a.replaceAll("\\", "/"));
1565 |         try
... (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/bundler_cjs2esm.test.ts"
bun test v1.4.0 (79f05d679)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [790.81ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [404.07ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [367.23ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [380.33ms]
(pass) bundler > cjs2esm/ExportsFunction [369.33ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [375.61ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [361.52ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [576.17ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [580.97ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProductionNoMinify [583.06ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopmentNoMinify [567.02ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvNoBracesNoMinify [547.45ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnDefineNumberNoMinify [561.06ms]
(pass) bundler 
... (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     79f05d6793
  features     baseline

22 deps, 106 codegen, 1175 objects in 712ms

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

Checked 124 installs across 170 packages (no changes) [18.00ms]
[2/1238] gen ErrorCode+*.h
[3/1238] gen bindgenv2
[4/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b58cd4685)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1238] fetch picohttpparser
[picohttpparser] up to date
[6/1238] fetch zlib
[zlib] up to date
[7/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1238] fetch tinycc
[tinycc] up to date
[9/1237] gen .bind.ts → GeneratedBindings.cpp
[10/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b58cd4685)

Checked 129 installs across 147 packages (no changes) [17.00ms]
[11/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from 
... (truncated)
diff hotspot
src/js_parser/parse/parse_entry.rs          |  43 ++++++-
 test/bundler/bundler_cjs2esm.test.ts        | 187 ++++++++++++++++++++++++++++
 test/bundler/transpiler/jsx-dev/jsx-dev.tsx |   4 +-
 test/bundler/transpiler/jsx-production.tsx  |   4 +-
 4 files changed, 233 insertions(+), 5 deletions(-)

gate history · 2 passed · 3 rejected · iteration 6

evidence per changed file
file                                         reads  edits  tests
src/js_parser/parse/parse_entry.rs               6      5      0
test/bundler/bundler_cjs2esm.test.ts             2      8      0
test/bundler/transpiler/jsx-dev/jsx-dev.tsx      2      1      0
test/bundler/transpiler/jsx-production.tsx       1      1      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The parser unwraps side-effect-free constant if statements before detecting module.exports = require(...) redirects. Bundler tests cover non-minified branch selection, React redirects, and JSX runtime output.

CommonJS redirect detection

Layer / File(s) Summary
Conditional branch unwrapping
src/js_parser/parse/parse_entry.rs
Evaluates side-effect-free conditional tests, selects the live branch, and uses it for CommonJS redirect detection.
Non-minified redirect coverage
test/bundler/bundler_cjs2esm.test.ts
Adds coverage for NODE_ENV branches, unbraced conditionals, numeric defines, else if chains, and React redirects without syntax minification.
JSX runtime output assertions
test/bundler/transpiler/jsx-dev/jsx-dev.tsx, test/bundler/transpiler/jsx-production.tsx
Checks development and production JSX runtime module names and rejects jsxDEV in production output.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The JSX assertion updates are not directly related to constant-test CommonJS redirect detection or issue #12726. Remove the unrelated JSX assertion changes or explain their required dependency on this redirect fix.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation addresses issue #12726 by removing the unnecessary __commonJS wrapper for React redirect modules and adds regression tests.
Title check ✅ Passed The title clearly summarizes the main bundler change: detecting CommonJS redirects through constant-test conditionals.
Description check ✅ Passed The description explains the change, cause, fix, reproduction, verification, affected tests, and linked issue in sufficient detail.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:50 PM PT - Aug 5th, 2026

@robobun, your commit 79f05d6793229bef276ff91cc86ae63bbf15413e passed in Build #89411! 🎉


🧪   To try this PR locally:

bunx bun-pr 35611

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

bun-35611 --bun

@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 couldn't find any issues here — the unwrap is tightly guarded and semantically equivalent to what --minify-syntax already does at the same point — but this is new pattern-matching in the bundler's CJS→ESM redirect path that changes emitted output, so it's worth a human look.

What was reviewed:

  • Confirmed Expr::as_bool() only matches EBoolean/EBranchBoolean literals, so the test expression is side-effect-free.
  • The unwrap only reassigns the local stmt; use_count_estimate == 1, import_records.len() == 1, and the exact module.exports = require(...) match all still gate the redirect, so a non-matching live branch falls through unchanged.
  • Checked the live = None / SEmpty / multi-statement-block cases — all leave stmt as the SIf, so no redirect fires (conservative).
  • Tests cover both branch polarities, braced/unbraced bodies, and the react ESM-namespace rewrite path; cjs2esm: true asserts the wrapper is gone.
Extended reasoning...

Overview

This PR extends the module.exports = require(...) redirect detector in src/js_parser/parse/parse_entry.rs to see through a residual if (<bool literal>) {} else { X } wrapper. The pattern arises when process.env.NODE_ENV is folded to a constant and DCE empties the dead branch, but --minify-syntax isn't on to flatten the SIf itself. The fix is a ~40-line unwrap that, when the test is a boolean literal and the dead branch is empty, reassigns the local stmt to the live branch's single statement before the existing SExpr match runs. Four new itBundled tests cover the matrix.

Security risks

None. This is compile-time AST pattern matching in the bundler; no untrusted input parsing, I/O, or auth surface is touched.

Level of scrutiny

Medium-high. The change is small and the safety argument is compelling: with --minify-syntax the SIf is already flattened to its live branch before this code runs, and the existing ModuleExportsBasedOnNodeEnv{Production,Development} tests (with minifySyntax: true) exercise the redirect on identical parser state. All downstream guards — use_count_estimate == 1, the exact EBinary(BinAssign, EDot(module.exports), ERequireString|EIdentifier) shape, and import_records.len() == 1 for the namespace path — are unchanged and still gate the redirect after the unwrap. as_bool() only returns Some for EBoolean/EBranchBoolean, so the test is guaranteed side-effect-free. If the live branch doesn't match, stmt is either left as the SIf or set to a non-SExpr, the match fails, and behavior is identical to before.

That said, this is the bundler's CJS→ESM output path — a wrong redirect here drops a __commonJS wrapper and rewires callers, which would break real bundles. It's not a mechanical or config-level change, so per the approval guidelines it warrants a human sign-off rather than auto-approval.

Other factors

The tests are well-constructed: cjs2esm: true asserts no __commonJS( in output, the react test additionally asserts no require_react wrapper and no dead-branch file in the bundle, and all four run the output to verify the correct branch's value is used. The existing ModuleExportsEqualsRuntimeCondition test (non-constant if test) still asserts the wrapper is kept, guarding against over-eager unwrapping.

Comment thread src/js_parser/parse/parse_entry.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: 1

🤖 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/bundler_cjs2esm.test.ts`:
- Around line 203-207: In the regression-test comments for
test/bundler/bundler_cjs2esm.test.ts at lines 203-207, retain only the issue URL
and remove the explanatory text; at lines 266-266, 294-296, 325-326, and
360-362, remove the explanatory comments entirely. Leave the associated tests
unchanged.
🪄 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: 3a8e9813-4ae3-4959-9eaa-8c9ebfd3fac2

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 5a89dd5.

📒 Files selected for processing (2)
  • src/js_parser/parse/parse_entry.rs
  • test/bundler/bundler_cjs2esm.test.ts

Comment thread test/bundler/bundler_cjs2esm.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.

I didn't find any correctness issues, but this changes bundler output for the CJS→ESM redirect path and adds ~50 lines of new pattern-matching in parse_entry.rs, so it warrants a human look. The comment-cop flag on the 15-line explanatory comment at parse_entry.rs:1253-1267 is also still outstanding.

What was reviewed:

  • Confirmed SideEffects::to_boolean is the same predicate s_if uses to mark the dead branch, and returns !ok when dead_code_elimination is off — so the unwrap can never disagree with DCE.
  • Checked that a non-empty dead branch (hoisted var, surviving fn decl, else-if with truthy outer test) fails dead_is_empty and falls through unchanged.
  • Verified the redirect path returns a fresh Ast::empty_in — the discarded if test/shell can't leak stale state; ModuleExportsEqualsRuntimeCondition still guards the non-constant case.
Extended reasoning...

Overview

Adds a while let SIf unwrap loop in parse_entry.rs just before the existing module.exports = require(...) redirect matcher. When the file's single non-trivial statement is an if whose test folds to a side-effect-free constant and whose dead branch has already been emptied by DCE, the loop peels down to the live branch's single statement so the existing SExpr/EBinary match can fire. Six new itBundled cases in bundler_cjs2esm.test.ts cover prod/dev NODE_ENV, unbraced bodies, numeric --define, else-if chains, and the real react/index.js shape with target: bun.

Security risks

None. This is a bundler output optimization; no untrusted-input parsing surface is added, and the only effect of a false positive would be dropping a __commonJS wrapper (behaviorally covered by the run.stdout assertions).

Level of scrutiny

Medium-high. The change is small and well-guarded, but it alters bundled output for every package using the NODE_ENV redirect pattern (react, react-dom, scheduler, etc.). The guards look right — to_boolean is exactly the predicate visit_stmt::s_if used to decide which branch to visit under is_control_flow_dead, and the dead_is_empty check means anything DCE had to preserve (hoisted var, fn decls) blocks the unwrap. On success the whole AST is replaced with Ast::empty_in(arena) + redirect_import_record_index, so no stale statement references survive. I traced the else-if-chain iteration and the loop terminates (each step descends to a strict AST child). One conservative gap I noticed but didn't flag as a bug: when the outer test is truthy and the else is itself an SIf, dead_is_empty is false and the unwrap bails — that's strictly no worse than today.

Other factors

The comment-cop action flagged the paragraph comment at line 1267 and CodeRabbit flagged the multi-line test comments; neither has been addressed since the last push. The tests themselves are solid — each asserts both runtime output and (for the react case) absence of require_react/react.production.min.js in the bundle, and the pre-existing ModuleExportsEqualsRuntimeCondition test still pins the non-constant case to unhandled. Given this affects the bundler's redirect fast-path across the ecosystem's most common package shape, a maintainer sign-off is appropriate.

Comment thread src/js_parser/parse/parse_entry.rs

@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 didn't find any bugs, but this touches bundler module-redirect detection where the correctness relies on DCE invariants (emptied dead branch, decremented module use count, import-record state), so it's worth a human look.

What was reviewed:

  • Verified SideEffects::to_boolean gates on dead_code_elimination and requires ok && NoSideEffects, so the peel only fires when the visitor already emptied the dead arm.
  • Traced the else-if chain case through the while loop; the single-statement live-branch guard and empty-dead-branch guard hold at each level.
  • Confirmed cjs2esm: true in the new tests asserts zero __commonJS wrappers and that the react test's onAfterBundle asserts the specific wrapper is gone.
  • The unresolved comment-cop note on parse_entry.rs:1256 re-fired after the trim; the current 4-line comment reads as a legitimate why-comment, likely a bot false positive.
Extended reasoning...

Overview

The PR extends the module.exports = require(...) redirect detector in parse_entry.rs to see through a constant-test if/else before matching the SExpr. Without --minify-syntax, DCE empties the dead arm but leaves the SIf shell (if (false) {} else { module.exports = ... }), which previously blocked the redirect. The new while loop peels SIf layers when SideEffects::to_boolean proves the test is a side-effect-free constant, the dead arm is empty (None/SEmpty/empty SBlock), and the live arm reduces to exactly one statement. Six new itBundled cases in bundler_cjs2esm.test.ts cover prod/dev NODE_ENV, unbraced arms, a numeric __DEV__ define, an else-if chain, and the real React index.js shape.

Security risks

None. This is a compile-time bundler optimization operating on already-parsed AST; no untrusted input parsing, no I/O, no allocation on external size fields.

Level of scrutiny

Moderate-to-high. The change is small (~35 lines of Rust) and additive — it only pre-processes stmt before the existing match — but it lives in a bundler correctness path. If the guards are wrong, the redirect could fire on a module whose if shell is semantically load-bearing, silently dropping code. The guards look sound (to_boolean itself returns ok=false when DCE is off; hoisted var/function in the dead arm would leave it non-empty so the peel bails; the downstream use_count_estimate == 1 gate is unchanged), and the test matrix is thorough. But the reasoning depends on DCE having already normalized use counts and emptied the branch in the visitor pass, which is the kind of cross-pass invariant a maintainer should confirm.

Other factors

  • The change reuses SideEffects::to_boolean, the same predicate the SIf visitor uses to pick the dead arm, so the two sites cannot disagree on which branch is live.
  • The cjs2esm: true harness flag does a strong assertion (zero __commonJS wrappers in output), and each test also runs the bundle to verify runtime behavior.
  • An unresolved comment-cop bot note remains on parse_entry.rs:1256 after the author trimmed the comment to four lines; the remaining comment states a non-obvious invariant and looks like a false positive, but the author may want to address or dismiss it.
  • No prior claude[bot] review on this PR.

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

No bugs found, but this touches the bundler's CJS→ESM redirect detection — worth a quick human sanity-check on the DCE invariant it relies on.

What was reviewed:

  • Confirmed SideEffects::to_boolean is the same predicate the s_if visitor uses (visit_stmt.rs:1699), and returns ok: false when DCE is off — so the unwrap can't fire without DCE having run.
  • The peeled stmt is detection-only; on no-match the original parts are emitted unchanged, and the module_ref use-count guard still gates the redirect.
  • Loop terminates (strictly descends the AST); dead_is_empty and single-statement-live-branch guards fall back to old behavior on anything unexpected.
  • New tests assert both zero __commonJS wrappers (via cjs2esm: true) and runtime stdout, covering both NODE_ENV values, unbraced bodies, numeric define, else-if chain, and the react namespace-identifier path.
Extended reasoning...

Overview

Adds a ~40-line preprocessing loop in src/js_parser/parse/parse_entry.rs that peels constant-test SIf statements down to their live arm's single statement before the existing module.exports = require(...) redirect matcher runs. This lets the redirect optimization fire on the standard React NODE_ENV switch pattern without --minify-syntax. Six new itBundled cases in test/bundler/bundler_cjs2esm.test.ts cover the variant matrix.

Security risks

None. This is a pure bundler output-shape optimization with no user-controlled inputs beyond source code that already flows through the parser. No auth, crypto, filesystem, or network surface.

Level of scrutiny

Medium-high. The bundler's CJS wrapping logic is a critical correctness path — a false positive here would drop a wrapper around a module that actually needs one, changing runtime semantics. The change is well-guarded: it reuses SideEffects::to_boolean (verified identical to what visit_stmt.rs:1699 uses to pick the dead arm), requires the dead arm to already be empty (proving DCE ran and left nothing behind), requires exactly one statement in the live arm, and only affects the local stmt used for pattern detection — the original parts are emitted unchanged if the downstream SExpr match fails. The existing use_count_estimate == 1 guard on module_ref still applies. The while loop handles else-if chains and terminates because each iteration descends to a strict subtree.

Other factors

Test coverage is strong: cjs2esm: true asserts zero __commonJS wrappers appear in output (the load-bearing assertion for #12726), and run: { stdout } asserts runtime correctness. The variant matrix covers production/development, braced/unbraced, numeric --define, else-if chains, and the react-specific path where require() was already rewritten to a namespace identifier. Bot review comments (comment length) have been addressed and resolved. Deferring because a maintainer familiar with the parser's DCE/minify interaction should confirm the invariant that an emptied dead arm plus a NoSideEffects test is sufficient to treat the if as a pure redirect wrapper — I believe it is, but this is exactly the kind of subtle bundler reasoning that benefits from a second pair of eyes.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

All green at 79f05d6: Buildkite #89411 passed (every lane), evidence gate passed (fail-before and pass-after on ASAN and release), clippy/format/lint green.

The branch now carries a merge of main; main's SideEffects::to_boolean refactor (Option<Known>) is adapted in 79f05d6. Ready to merge.

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

No issues found. Deferring only because this changes bundler output for the react NODE_ENV redirect shape and required updating the jsx-production/jsx-dev fixtures — worth a quick maintainer glance at those fixture edits.

What was reviewed:

  • Unwrap loop is gated on SideEffects::to_boolean (returns ok=false when DCE is off) and an already-emptied dead arm, so it only reaches the same post-DCE state --minify-syntax would have flattened; downstream module_ref use-count and import-record matching are unchanged.
  • cjs2esm: true in the new tests exercises the intended harness path (single outfile, no minify) and asserts zero __commonJS wrappers; the runtime-condition negative case still lists all three files as unhandled.
  • jsx fixture edits swap the namespace-name check for the redirect-target filename + jsxDEV( presence; the stdout assertion in jsx-production.test.ts is unchanged, so behavioral coverage is preserved.
Extended reasoning...

Overview

The PR extends the module.exports = require(...) redirect detector in src/js_parser/parse/parse_entry.rs to look through a surviving SIf whose test is a compile-time constant with no side effects and whose dead arm has already been emptied by DCE. This lets files shaped like react's index.js NODE_ENV switch collapse to a redirect without --minify-syntax. Six new itBundled cases cover both NODE_ENV values, braced/unbraced bodies, a numeric __DEV__ define, an else-if chain, and the real react package layout with explicit not.toContain checks on the wrapper. Two jsx transpiler fixtures were updated because the react/jsx-runtime wrapper is now eliminated from their bundle output.

Security risks

None. This is AST pattern matching in the parser with no untrusted-input parsing, no allocations, and no FFI.

Level of scrutiny

Medium. The parser change is ~40 LOC, purely additive (a peel loop before the existing match), and provably equivalent to the state --minify-syntax already produces — the same SideEffects::to_boolean predicate that the s_if visitor uses to pick the dead arm gates the unwrap here, and it short-circuits when dead_code_elimination is off. The use_count_estimate == 1 and import-record checks that make the redirect sound are unchanged and run against the same post-DCE symbol/import state either way. The pre-existing ModuleExportsEqualsRuntimeCondition test (non-constant test, all three files stay wrapped) still holds. That said, this changes emitted output for the single most-bundled npm package, and it required editing two existing test fixtures.

Other factors

  • The jsx fixture updates replace a namespace-member check (jsx_dev_runtime.jsxDEV) with a target-file check (jsx-dev-runtime.development) plus a jsxDEV( presence/absence check. The bundled code is still executed via import(url) and the parent test still asserts exact stdout, so the property under test is preserved and arguably tightened (prod now also asserts jsxDEV( is absent). Per REVIEW.md this is exactly the case where a reviewer should confirm the edit keeps asserting the original property — I believe it does, but a maintainer familiar with #3768 should confirm.
  • All bot feedback (comment-cop, coderabbit) is resolved; the four-line comment in parse_entry.rs documents the invariant tying the unwrap to the DCE predicate, matching the surrounding block's existing 20-line NODE_ENV comment.
  • The cjs2esm: true harness path defaults outfile via useOutFile = entryPoints.length === 1, so the harness guard at expectBundled.ts:685 does not fire for the new tests.

@robobun
robobun force-pushed the farm/af8da350/bundler-cjs-redirect-through-if branch from 28ce7bb to e90649f Compare July 25, 2026 16:16

@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 didn't find any issues, but this changes bundler output shape for the ubiquitous NODE_ENV redirect pattern (react, react-dom, etc.) and required updating two existing jsx-runtime test fixtures to match, so it's worth a human look — especially since CI test lanes haven't run yet due to the build-cpp agent expiry.

What was reviewed

  • The SIf peel loop uses the same SideEffects::to_boolean predicate as the s_if visitor (visit_stmt.rs:1699) and bails on !ok, side-effecting tests, non-empty dead arms, or multi-statement live blocks — the unwrapped stmt is a local copy so a non-match falls through unchanged.
  • The unchanged use_count_estimate == 1 guard on module_ref still gates the redirect, so a stray module reference in the dead arm that survived DCE would already have blocked it.
  • cjs2esm: true without minifySyntax in the new tests is fine — expectBundled.ts:676 defaults outfile for single-entry cases, so the guard at :685 doesn't trip.
  • The jsx fixture updates keep asserting the same property (correct runtime file bundled + correct jsx call) rather than weakening it.
Extended reasoning...

Overview

Adds a ~40-line while loop in parse_entry.rs that peels constant-test SIf wrappers before the existing module.exports = require(...) redirect match runs. This lets the redirect fire on the standard if (process.env.NODE_ENV === 'production') ... else ... shape without --minify-syntax, dropping the __commonJS wrapper around files like react/index.js. Six new itBundled cases cover both NODE_ENV values, braced/unbraced bodies, a numeric --define, an else-if chain, and the react package path with an onAfterBundle output-shape assertion. Two jsx transpiler fixtures are updated because the jsx_runtime namespace object no longer appears in the output.

Security risks

None. This is a compile-time bundler optimization with no auth, crypto, or untrusted-input parsing surface. The peel is read-only over the already-visited AST and only narrows when a redirect is emitted; on any bail it falls through to the pre-existing behavior.

Level of scrutiny

Medium-high. The logic is small and conservatively guarded, but it changes bundler output for one of the most common package shapes in the npm ecosystem. A subtle mistake here (e.g., dropping a side-effecting test or a hoisted declaration in the dead arm) would produce broken bundles for react users. I checked each guard against the corresponding visitor behavior and they line up, but a maintainer who owns this code path should confirm the interaction with imports_to_convert_from_require / import_records bookkeeping in the react-namespace case (the EIdentifier branch at :1324).

Other factors

  • CI: builds #80379/#80888 hit the pipeline-wide build-cpp agent-expiry issue, so no test lanes ran. The author's evidence-gate ASAN run shows fail-before/pass-after for the 6 new cases and 29/29 for the file, plus local verification of jsx-production.test.ts and bun-serve-html.test.ts, but there's no full-suite CI signal.
  • All prior review threads (comment-cop, coderabbit) are resolved.
  • The jsx fixture rewrite is a legitimate consequence of the output change, not a test weakening — it now asserts on the underlying runtime filename plus the presence/absence of jsxDEV(, which is at least as strong as the old namespace-name check.

…test if/else

Without --minify-syntax, the NODE_ENV switch in react/index.js survives
as 'if (<const>) {} else { module.exports = X }' after DCE empties the
dead arm. The redirect detector only matched a bare SExpr, so the file
kept its __commonJS wrapper.

Peel the SIf to its live arm's single statement (using the same
SideEffects::to_boolean predicate DCE used to empty the dead arm) before
running the existing module.exports = require() match.

Fixes #12726.
@robobun
robobun force-pushed the farm/af8da350/bundler-cjs-redirect-through-if branch from e90649f to ef2deaf Compare August 1, 2026 08:08

@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 — targeted extension of the existing module.exports = require() redirect detector to see through a constant-test if, matching what --minify-syntax already produces.

What was reviewed:

  • Unwrap loop uses the same SideEffects::to_boolean predicate as the SIf visitor and additionally requires NoSideEffects + an already-emptied dead arm, so it cannot fire unless DCE already ran on that branch.
  • Downstream match is unchanged (still requires SExpr + use_count_estimate == 1), so unwrapping to anything else falls through harmlessly; existing negative test ModuleExportsEqualsRuntimeCondition still guards the non-constant case.
  • The jsx-dev / jsx-production fixture updates are required by the output-shape change (react's jsx-runtime index is exactly this pattern) and assert the same property at least as strictly.
Extended reasoning...

Overview

Adds a ~38-line while let SIf unwrap loop in src/js_parser/parse/parse_entry.rs immediately before the existing module.exports = require(...) redirect match. When the file's single non-trivial statement is if (<const>) {} else { <stmt> } (or the reverse), it peels to the live arm's single statement so the existing match can fire. Six new itBundled cases in bundler_cjs2esm.test.ts cover both NODE_ENV polarities, braced/unbraced bodies, a numeric --define, an else-if chain, and the real react package path. Two JSX fixtures update their string-match assertions to track the new (wrapper-free) output shape.

Security risks

None. This is a bundler output-shape optimization; no untrusted input reaches unsafe code, and the transform is semantically identical to what --minify-syntax already emits.

Level of scrutiny

Moderate — parse_entry.rs runs on every bundled file, but the new code is inside the existing parts.len() < 4 && unwrap_commonjs_to_esm guard and only iterates when the single non-trivial statement is an SIf. Every non-matching condition (!effects.ok, side effects, non-empty dead arm, multi-statement live block, SEmpty live arm) breaks out and falls through to the pre-existing behavior. The downstream use_count_estimate == 1 and SExpr-shape checks are untouched, so an over-eager unwrap still cannot produce a redirect unless the resulting statement is exactly module.exports = <ERequireString | namespace-id>.

Other factors

I compared the loop's guards against visit_stmt.rs:1699-1738: the SIf visitor picks the dead arm using only effects.ok, whereas this loop additionally requires side_effects == NoSideEffects, which is strictly more conservative. The dead-arm emptiness check mirrors the SEmpty | SBlock{len==0} pattern the visitor uses to trim else {}. should_keep_stmt_in_dead_control_flow survivors (hoisted var/function) would leave the block non-empty, so the loop breaks. The while handles else-if chains and is exercised by ModuleExportsBasedOnNodeEnvElseIfChainNoMinify. The fixture change in jsx-production.tsx / jsx-dev.tsx swaps the old wrapper-namespace substring for the underlying module filename plus a jsxDEV( presence/absence check — the runtime assertion (<div>Hello World</div> printed twice) in jsx-production.test.ts is unchanged. All prior bot feedback (comment-cop, CodeRabbit) is resolved; no human review comments outstanding.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread src/js_parser/parse/parse_entry.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.

The compile break I flagged earlier is fixed in 79f05d6 — the peel loop now uses the Option<Known> shape and matches the other to_boolean callers. I didn't find further issues, but this changes bundler output shape for the react-style NODE_ENV redirect (and required rewriting the jsx-production/jsx-dev fixture assertions to match), so it's worth a human look.

What was reviewed:

  • Peel-loop guards: to_boolean returns None when DCE is off, side-effect check bails, dead arm must already be empty, live arm must be exactly one stmt — falls through to old behavior on any miss.
  • while handles else-if chains and single-stmt blocks; live == None and SEmpty both break cleanly.
  • use_count_estimate == 1 gate is unchanged and already held for the minify-syntax path, so the same DCE bookkeeping applies here.
  • New cjs2esm/*NoMinify cases cover both NODE_ENV values, unbraced bodies, numeric define, else-if chain, and the react package path; each has a run stdout assertion.
Extended reasoning...

Overview

Extends the module.exports = require(...) redirect detector in parse_entry.rs to look through a surviving if (<const>) {} else { ... } shell that remains when DCE has emptied the dead arm but --minify-syntax isn't on to flatten the SIf. Adds a ~35-line while peel loop before the existing SExpr match; the match itself and the redirect return are unchanged. Six new itBundled cases in bundler_cjs2esm.test.ts cover the variant matrix, and two JSX transpiler fixtures are updated because react/jsx-runtime now collapses to its target and the bundle no longer contains a jsx_runtime. namespace indirection.

Prior review

My previous inline finding (semantic merge conflict — SideEffects::to_boolean now returns Option<Known>) is resolved by 79f05d6. The new code uses let Some(effects) = ... else { break } and reads effects.side_effects / effects.value off Known, matching visit_stmt.rs and visit_binary.rs.

Security risks

None. This is a pure bundler output-shape optimization gated on DCE having already run; no untrusted-input parsing, no FFI, no allocation changes.

Level of scrutiny

Medium-high. The logic is small and conservatively guarded — every condition that fails to hold breaks back to the pre-existing path — but it changes emitted bundle shape for one of the most common package patterns in the ecosystem (react's NODE_ENV switch), and it required editing two existing test fixtures to keep them passing. The new fixture assertions (jsx-runtime.production filename + absence of jsxDEV() look equivalent in intent to the old ones (jsx_runtime.jsx namespace var), and the runtime stdout checks in those fixtures are unchanged, but a maintainer should confirm they're happy with the assertion swap.

Other factors

The green Buildkite run cited in the PR (#86923) predates the merge with main; #89411 is the build on the current HEAD (79f05d6) and its status wasn't visible to me. The cjs2esm/ModuleExportsEqualsRuntimeCondition test (unknown-at-build-time branch) sits immediately after the new tests and continues to assert the wrapper is kept when the test isn't foldable, which is the negative case for this change.

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.

react production bundle includes more module wrappers than necessary

3 participants