Repository navigation
Conversation
An unused "(a, b) ? f() : 0" is rewritten to "a, b && f()", which turns "a" into an unused expression of its own. It was never simplified, so side-effect-free operands survived one transpile and were removed by the next one. Simplify the hoisted operands while joining the test with the remaining branch, so the output is fully simplified in one pass.
|
Warning Review limit reached
Next review available in: 30 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: fix pushed, waiting for CI. Reproduced on the released 1.4.0 build with |
There was a problem hiding this comment.
LGTM — focused simplifier fix with strong test coverage.
What was reviewed:
- Traced
join_unused_test_with_branchagainstjoin_with_left_associative_op,join_with_comma, andto_empty: non-comma tests fall through unchanged; left- and right-nested comma chains preserve evaluation order. - Checked that the loop leaves
testnon-comma sojoin_with_left_associative_opcannot hoist further unused operands, and that recursion inherits the existing stack-check viasimplify_unused_expr. - Verified the 17 test cases cover both
&&/||arms, declared vs. undeclared identifiers, nested contexts ([],!, inner ternary, RHS of&&), and the fixed-point property.
Extended reasoning...
Overview
This PR touches one function in src/js_parser/scan/scan_side_effects.rs (the EIf arm of SideEffects::simplify_unused_expr) and adds a 17-case it.each block to test/bundler/transpiler/transpiler.test.js. The two join_with_left_associative_op calls are replaced with a new local helper join_unused_test_with_branch that first walks the comma chain of the ternary's test, passes each hoisted operand through simplify_unused_expr, and only then hands the last (still-used) operand to join_with_left_associative_op. Net Rust delta is ~30 lines including a doc comment.
Security risks
None. This is a pure AST-simplification refinement inside the transpiler's dead-code-elimination pass. No untrusted-input parsing, allocation sizing, FFI, or filesystem/network surface is touched. Expr/ExprData are Copy arena-backed values, so there is no new ownership or lifetime concern; the helper reuses the existing simplify_unused_expr recursion (with its stack check) and join_with_comma (which already handles EMissing).
Level of scrutiny
Medium. The transpiler runs on every JS file, so a semantic mistake here would miscompile user code. I traced the helper against the underlying primitives: for a non-comma test the loop is skipped and behavior is byte-identical to before; for left-associated ((x1, x2), x3) and right-associated (x1, (x2, x3)) chains the loop preserves left-to-right evaluation order and only simplifies operands whose value is discarded — exactly the contract of simplify_unused_expr. After the loop test is guaranteed not to be a comma, so join_with_left_associative_op cannot introduce further unused operands. The undeclared-identifier case ((u, b) ? f() : 0) correctly keeps u because simplify_unused_expr returns Some for unbound identifiers.
Other factors
Test coverage is unusually thorough for a change this size: both surviving-branch polarities, declared vs. undeclared identifiers, longer and nested comma chains, side-effecting operands at each position, and the ternary nested inside [], !, another ternary, and the RHS of && — each asserting exact one-pass output and that a second transpile is a fixed point. The PR description additionally reports a 3×3000-program semantic-diff fuzzer run with zero divergences. The let-chain while let ... && ... syntax is already used elsewhere in src/js_parser/. No prior human review comments to address.
Problem
new Bun.Transpiler({ loader: "js" }).transformSync("(0, b) ? f() : 0;")prints0, b && f();. Transpiling that output again printsb && f();, so the output of one pass is not a fixed point. Same for(0, b) ? 0 : f();(0, b || f();), for longer or nested comma chains ((0, 1, b),(0, (1, b))), for declared variables in place of the literal, and for the ternary nested in another unused expression ([... ],!,void, the right side of&&).EIfarm ofSideEffects::simplify_unused_expr(src/js_parser/scan/scan_side_effects.rs:180-196) rewrites an unusedtest ? yes : <removed>withExpr::join_with_left_associative_op, which hoists a comma test out of the operator:(a, b) && f()becomesa, (b && f()). The hoistedais an unused expression from that point on, but nothing simplifies it. The second transpile sees it as the left operand of an unused comma statement (simplify_unused_binary_comma_expr) and drops it.minify: { syntax: true }the visitor already simplifies comma left operands while visiting the comma (src/js_parser/visit/visit_binary.rs:201). That is also why the esbuild code this arm was ported from does not need to do anything here: esbuild only runs this simplification when minifying.Fix
EIfarm joins the test and the surviving branch through a newjoin_unused_test_with_branch. It walks the comma chain of the test, passes each hoisted operand throughsimplify_unused_expr(dropping it onNone), and joins the last operand, the only one whose value is still used, with the branch viajoin_with_left_associative_opexactly as before. A test that is not a comma takes the same path as before.x1, ..., xn ? yes : <removed>the operandsx1..xn-1are evaluated only for their effects, and keeping exactly the effects of an unused expression is whatsimplify_unused_exprdoes (so(u, b) ? f() : 0with an undeclaredustill printsu, b && f(), since readingucan throw).xnfeeds the&&/||and is left alone.minify: { syntax: true }andesbuild --minify-syntaxalready print (b && f(),g(), b && f(),b, c && f(),b && c && f(),a && (c && f())).ucase), all pass with the fix.Background
simplify_unused_expr(expr)takes an expression whose value is not used (an expression statement, the branch of an unused ternary, an item of an unused array literal, ...) and returns an expression that performs the same side effects and nothing else, orNonewhen there are none. It drops literals and reads of declared variables and keeps calls, assignments and reads of undeclared identifiers (those can throw). It runs on every expression statement whenever dead code elimination is enabled, which is the default forBun.Transpiler,bun buildand the runtime, not only when minifying.Expr::join_with_left_associative_op(op, a, b)buildsa op bwhile rotating the tree so that the printer needs no parentheses: a comma on the left is hoisted out ((x, y) op bbecomesx, (y op b)) and the same operator on the right is re-associated (a op (b op c)becomes(a op b) op c). The hoisting is what creates the new unused operands this PR simplifies.Random probe
Programs are one unused expression statement built from commas, ternaries,
&&,||, array literals and!over literals, declared variables, an undeclared identifier and three logging calls. Each program is transpiled twice; the test checksp1 === p2and runs the source,p1andp2, comparing the call logs (or the thrown error class).The only minify-mode divergence the probe finds is identical before and after this change (
!(a, "s")printing as(a, !"s"), a missing string case inExpr::maybe_simplify_not) and is tracked separately.