Skip to content

js_parser: stop the [x][0] / {f:x}.f folds from changing optional-chain / this / assignment semantics - #36730

Merged
Jarred-Sumner merged 6 commits into
mainfrom
farm/40497af5/array-index-inline-optional-chain
Aug 4, 2026
Merged

js_parser: stop the [x][0] / {f:x}.f folds from changing optional-chain / this / assignment semantics#36730
Jarred-Sumner merged 6 commits into
mainfrom
farm/40497af5/array-index-inline-optional-chain

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

With minify_syntax on (the default for bun run), the [x][0] -> x and {f: x}.f -> x folds were replacing an index/property Reference with a bare value without fully checking the parent context. All of these reproduce against Node on current main:

var a = null;
[[a?.b]][0]?.[0].c;         // Node: TypeError   Bun: undefined    (output: a?.b.c)
new ({f: a?.b}).f();        // Node: runs        Bun: SyntaxError  (output: new a?.b)

var obj = { m() { return this === obj } };
[obj.m][0]();               // Node: false       Bun: true         (output: obj.m())

var o = { p: 1 };
[o.p][0] = 5; o.p;          // Node: 1           Bun: 5            (output: o.p = 5)
"foo"[2] = 1;               // Node: no-op       Bun: SyntaxError  (output: "o" = 1)

The direct form [a?.b]?.[0].c is hidden on main because the parse-time "drop ?. on a non-nullable literal" simplification clears the outer chain first and the printer inserts (a?.b); one level of indirection ([[a?.b]][0]?.[0].c, ({f:[a?.b]}).f?.[0].c) bypasses that.

Fix

  • can_be_inlined_from_property_access now rejects EDot/EIndex/ECall whose optional_chain is set, so an optional chain is never spliced onto a surrounding ?. continuation and never lands as a bare new / template-tag target.
  • Both folds now route through that predicate (the {f: x}.f fold previously did not).
  • The e_index fold bails when in_.assign_target != None, so [x][0] = v, "s"[n] = v and [,][0] = v are left as written instead of producing x = v / "s" = v / void 0 = v.
  • When the e_index fold is a call target and the inlined item is an EDot/EIndex, it emits (0, x)() instead of x(), matching the sibling comma/??/||/&&/ternary folds so this is not rebound.
  • The multi-item path's debug_assert! on the predicate becomes a real guard (reachable via a /* @__PURE__ */ a?.() array item).

The folds still fire for plain targets: [a.b][0].c -> a.b.c, [[y]][0]?.[0].c -> y.c, [y][0]() -> y(), ({f: y}).f -> y, new ({f: C}).f() -> new C. esbuild does not perform these folds at all, so the remaining bailouts bring Bun in line with its output.

Out of scope (tracked separately)

Two pieces of parser-context tracking were never wired up in the Rust port, so the corresponding parent positions are not yet covered here for plain (non-chain) items:

  • p.delete_target is never set by the UnDelete arm, so delete [obj.p][0] still folds to delete obj.p. Wiring it up turns on every dormant is_delete_target guard in the visitor (bundler "cannot assign to import", CJS-named-export deopt, enum/namespace inlining under delete).
  • is_template_tag is commented out, so [obj.m][0]x`` still rebinds this. Wiring it up touches every sibling fold (comma/`??`/`||`/`&&`/ternary).

Both are pre-existing and have a blast radius beyond this fold; the optional-chain bailout already keeps delete [a?.b][0] and [a?.b][0]x`` unfolded in the meantime.

Verification

  • bun bd test test/bundler/transpiler/transpiler.test.js -t "property access inlining" passes (8 tests); the three new tests fail with src/ reverted to main.
  • Full transpiler.test.js (186 pass / 0 fail) and bundler_minify.test.ts (42 pass / 0 fail) are green.

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

fails on main (without fix)
ASAN without fix: 3 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (aa68cffb9)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.64ms]
(pass) Bun.Transpiler > normalizes \r\n [5.92ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [3.87ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [6.99ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.54ms]
(pass) Bun.Transpiler > property access inlining > works [2.17ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.49ms]
76 |     transpiledOutput: code => {
77 |       return ts.parsed(code, false, false);
78 |     },
79 | 
80 |     expectPrintedMin_: (code, out) => {
81 |       expect(ts.parsedMin(code, !out.endsWith(";\n"), false)).toBe(out);
                                                                   ^
error: expect(received).toBe(expected)

Expected: "x = [a?.b]?.[0].c"
Received: "x = a?.b.c"

      at expectPrintedMin_ (/workspace/bun/test/bu
... (truncated)

release without fix: 2 failed, 22 skipped
bun test v1.4.0-canary.1 (8786d337f)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [2.42ms]
(pass) Bun.Transpiler > normalizes \r\n [0.15ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [0.08ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.14ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.03ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.03ms]
76 |     transpiledOutput: code => {
77 |       return ts.parsed(code, false, false);
78 |     },
79 | 
80 |     expectPrintedMin_: (code, out) => {
81 |       expect(ts.parsedMin(code, !out.endsWith(";\n"), false)).toBe(out);
                                                                   ^
error: expect(received).toBe(expected)

Expected: "x = new { f: a?.b }.f"
Received: "x = new a?.b"

      at expectPrintedMin_ (/workspace/bun/test/bundler/transpiler/transpiler.test.js:81:63)
      at <anonymous> (/workspace/bun/test/bundler/transpiler/transpiler.test.js:182:10)
(fail) Bun.Transpiler > proper
... (truncated)
passes on PR (with fix)
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (aa68cffb9)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.75ms]
(pass) Bun.Transpiler > normalizes \r\n [6.06ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely #2746 [4.03ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [7.03ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.56ms]
(pass) Bun.Transpiler > property access inlining > works [2.25ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.63ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [45.16ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [21.39ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [324.61ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 730ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] 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_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap 
... (truncated)
diff hotspot
src/ast/expr.rs                            |   5 ++
 src/js_parser/fold.rs                      |   1 +
 src/js_parser/visit/visit_expr.rs          |  36 +++++----
 test/bundler/transpiler/transpiler.test.js | 118 +++++++++++++++++++++++++++++
 4 files changed, 145 insertions(+), 15 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
src/ast/expr.rs                                 3      3      0
src/js_parser/fold.rs                           2      1      0
src/js_parser/visit/visit_expr.rs               8      4      0
test/bundler/transpiler/transpiler.test.js      5     14      0

The minify_syntax fold that turns `[x][0]` into `x` was willing to
inline an optional chain expression. When the surrounding index is
itself the start of an optional chain (so the next access is a
continuation), the printer splices the two chains together:

    [[a?.b]][0]?.[0].c

became `a?.b.c`, which short-circuits to `undefined` for `a == null`
instead of throwing on the trailing `.c` like the original (and
Node/esbuild) do.

The direct `[a?.b]?.[0].c` form was hidden because the parse-time
`?.`-on-literal simplification drops the outer chain first, but one
level of indirection (`[[a?.b]][0]?.[0].c` or
`({f:[a?.b]}).f?.[0].c`) exposes it on current main.

Teach can_be_inlined_from_property_access to reject EDot/EIndex/ECall
nodes that carry an optional chain, and turn the downstream
debug_assert into a real guard so the multi-item path can bail in
release builds too.
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; CI red is unrelated.

Reproduced on main:

  • bun -e 'var a=null; console.log([[a?.b]][0]?.[0].c)' prints undefined (Node: TypeError)
  • bun -e 'var a={b:class{}}; new ({f: a?.b}).f()' fails with SyntaxError (Node: runs)
  • bun -e 'var obj={m(){return this===obj}}; console.log([obj.m][0]())' prints true (Node: false)
  • bun -e 'var o={p:1}; [o.p][0]=5; console.log(o.p)' prints 5 (Node: 1)

Three new tests under "property access inlining" in test/bundler/transpiler/transpiler.test.js fail with src/ reverted and pass with the fix. transpiler.test.js passed on every lane in #87482.

Remaining red on #87482 is unrelated to this diff:

  • test/cli/install/bun-upgrade.test.ts on windows-aarch64: "Canary builds are not available for this platform yet" (missing published binary; reported for main-break triage).
  • robobun agent-creation: AWS EC2 400 "Unsupported configuration" for the alpine-3.23-x64 runner.
  • Everything else is [flaky] (passed on retry).

The p.delete_target / is_template_tag wiring gaps are pre-existing and tracked separately (see #36734 for delete_target).

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Array-property inlining

Layer / File(s) Summary
Guard indexed-access inlining
src/ast/expr.rs, src/js_parser/fold.rs, src/js_parser/visit/visit_expr.rs
Optional-chain continuations and assignment targets are excluded from unsafe rewrites. Sparse arrays and method-call receiver semantics are preserved.
Validate transformed expression semantics
test/bundler/transpiler/transpiler.test.js
Regression tests cover optional chains, calls, assignments, deletion, constructors, tagged templates, and runtime behavior.

Possibly related PRs

  • oven-sh/bun#36734: Both PRs prevent unsafe indexed-property inlining in context-sensitive targets, including assignments and call receivers.

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 identifies the parser folds that are fixed and the affected optional-chain, this-binding, and assignment semantics.
Description check ✅ Passed The description explains the problem, fix, scope, and verification results with sufficient technical detail.

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

@github-actions github-actions Bot added the claude label Aug 1, 2026

@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 — the optional-chain bailout is correct and narrowly scoped, and the inline note about the pre-existing is_call_target/is_delete_target/assign_target gap at the same fold is worth a follow-up but doesn't block this fix (behavior there is byte-identical before and after).

Extended reasoning...

Overview

Adds three arms to Expr::can_be_inlined_from_property_access so that EDot/EIndex/ECall with a non-None optional_chain are rejected, and converts the multi-item path's debug_assert! on the same predicate into a real runtime guard. The predicate has exactly two callers (visit_expr.rs:1091 and :1108), both at the [x][0] -> x fold, so the blast radius is fully contained. Tests cover all three chain node kinds, the object-literal fold path, non-chain items still inlining, and an end-to-end subprocess check that the TypeError is preserved.

Security risks

None. Pure JS AST simplification predicate; no I/O, allocation, or FFI changes.

Level of scrutiny

Medium — parser/minifier correctness is user-visible via bun run, but the change is a strict narrowing of an existing predicate (previously _ => true covered these variants). It can only cause the fold to bail more often, never fire in new cases, so the failure mode is "slightly less minified" rather than new miscompiles. The debug_assert!if conversion also removes a release/debug divergence.

Other factors

  • Verified the predicate has no other callers in src/.
  • Tests follow harness conventions (bunExe/bunEnv, concurrent pipe drain, exit code asserted last) and include negative cases confirming plain a.b and [[y]][0]?.[0].c still fold.
  • The inline finding about [obj.m][0]() / delete [obj.p][0] / [obj.p][0] = 5 is pre-existing — those inputs hit the _ => true wildcard on main and produce identical output with or without this patch. It's the same fold site but a different fix shape (needs parent-context checks + (0, x) emission, matching the sibling comma/??/ternary folds), so scoping this PR to the optional-chain splice and taking that as a follow-up is reasonable.

Comment thread src/js_parser/visit/visit_expr.rs
Expand the same fold site to also respect the parent expression:

- As a call target: `[obj.m][0]()` now emits `(0, obj.m)()` instead of
  `obj.m()`, matching the sibling comma/??/||/&&/ternary folds, so
  `this` inside `m` is not rebound to `obj`.
- As an assignment target: `[obj.p][0] = v`, `[,][0] = v`,
  `"s"[n] = v` stay as written instead of folding to
  `obj.p = v` / `void 0 = v` / `"s" = v` (the last two are
  SyntaxErrors in the emitted output).
- Add coverage for the Continuation (a?.b.c) and multi-item
  @__PURE__ removable paths that reach the second fold arm.

The `!is_delete_target` check is included for completeness but is
currently always false because `p.delete_target` is never set by the
UnDelete handler; that is tracked separately.
Comment thread src/ast/expr.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread src/ast/expr.rs
@robobun robobun changed the title js_parser: don't inline an optional chain out of [x][0] js_parser: stop the [x][0] fold from changing optional-chain / this / assignment semantics Aug 1, 2026

@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/js_parser/visit/visit_expr.rs`:
- Around line 1060-1064: Track whether the original EIndex is the delete target
before Op::UnDelete visits its operand, and use that state when applying the
minify_syntax temporary-expression folding guard in the expression visitor. Add
an isolated plain-property regression case for delete [obj.p][0] in
transpiler.test.js, preserving the existing optional-chain coverage.

In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 226-232: Remove the exact-empty stderr assertion from the
subprocess test using Bun.spawn. Continue draining proc.stderr, and validate the
expected outcome through stdout and exitCode (and signal if applicable), leaving
stderr available only for diagnostics.
🪄 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: 6adaeb19-013b-4156-82c3-3a1f7b0fc2c7

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and 8786d33.

📒 Files selected for processing (3)
  • src/ast/expr.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread test/bundler/transpiler/transpiler.test.js

@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/ast/expr.rs:65-67 — 🟣 Pre-existing, but the direct sibling of the fold this PR hardens: the {f: x}.f -> x object-literal fold at src/js_parser/fold.rs:487 returns Some(value) without calling can_be_inlined_from_property_access(), so new ({f: a?.b}).f() minifies to new a?.b and ({f: a?.b}).f`x` minifies to a?.b`x` — both hard SyntaxErrors. The one-line fix is to gate that return on value.can_be_inlined_from_property_access(), reusing exactly the predicate this PR just hardened; per REVIEW.md "Fix the whole class in the same PR — grep for every sibling site sharing the pattern", worth folding in here (or as an immediate follow-up).

    Extended reasoning...

    What the bug is

    The {f: x}.f -> x object-literal fold in maybe_rewrite_property_access (src/js_parser/fold.rs:459-490) returns Some(value) at :487 unconditionally once the property matches. Its guards at :470-473 are !is_delete_target && assign_target == None && !is_call_target — there is no check on whether value is an optional-chain expression. When value is a?.b and the parent is new or a tagged template, the minifier emits syntactically invalid JavaScript:

    $ echo 'x = new ({f: a?.b}).f()' | bun build --minify-syntax --no-bundle /dev/stdin
    x = new a?.b;         // SyntaxError: Invalid optional chain from new expression
    
    $ echo 'x = ({f: a?.b}).f`x`' | bun build --minify-syntax --no-bundle /dev/stdin
    x = a?.b`x`;          // SyntaxError: Invalid tagged template on optional chain
    

    Also new ({f: a?.[b]}).f()new a?.[b] and new ({f: a?.b.c}).f()new a?.b.c (Continuation on the outermost node).

    Step-by-step proof (new case)

    Input: new ({f: a?.b}).f()

    1. e_new visits its target without setting p.call_target (visit_expr.rs:2323-2326 does a plain p.visit_expr(&mut e_.target); only e_call at :1854 sets p.call_target). So when e_dot runs on .f, it computes is_call_target = false at :1349.
    2. e_dot calls maybe_rewrite_property_access at :1434, gated on e_.optional_chain.is_none() — true for the plain .f. It passes is_call_target=false, is_delete_target=false, assign_target=None.
    3. The object fold fires (fold.rs:470-487): single property, all three guards pass, INLINE_PROPERTIES_IN_TRANSPILER = true, key matches "f". Returns Some(a?.b) — an EDot with optional_chain = Some(Start).
    4. The ENew printer does not wrap it. js_printer/lib.rs:3048 prints the target at Level::New with ExprFlag::forbid_call() only — it does not pass HasNonOptionalChainParent. The EDot printer arm (:3256-3306) has no level-based paren wrap; it wraps only when flags.contains(HasNonOptionalChainParent) (:3271), which is false here. Output: new a?.b — a SyntaxError in every JS engine.

    Step-by-step proof (tagged-template case)

    Input: ({f: a?.b}).f`x`

    1. e_template (visit_expr.rs:696) visits its tag via a plain p.visit_expr — again no call_target tracking, so the object fold fires exactly as above and the tag becomes the raw a?.b.
    2. The ETemplate printer's optional-chain wrap check at js_printer/lib.rs:3751 matches on expr.data (the ETemplate node itself), not on tag.data, so it always falls through to _ => false. The tag then prints at :3762 with ExprFlag::none() — no HasNonOptionalChainParent. Output: a?.b`x` — SyntaxError.

    (The ECall variant new ({f: a?.()}).f() happens to survive because the ECall printer wraps at level.gte(Level::New), but EDot/EIndex do not.)

    Why this PR's fix does not cover it

    can_be_inlined_from_property_access() — the predicate this PR hardens to reject optional-chain EDot/EIndex/ECall — has exactly one caller: the array fold at visit_expr.rs:1113. The object fold at fold.rs:487 never calls it.

    The chain-splice variant this PR primarily targets cannot reach the object fold, because both callers of maybe_rewrite_property_access (visit_expr.rs:1037 for e_index, :1435 for e_dot) are gated on e_.optional_chain.is_none() — so the parent can never be a Continuation. But the new and tagged-template parent positions still miscompile, because neither e_new nor e_template sets any tracking the fold could consult, and neither printer passes HasNonOptionalChainParent to its target.

    Why existing guards don't catch it

    The object fold's only guards are !is_delete_target && assign_target == None && !is_call_target. There is no optional-chain check on value, and is_call_target is false under new/template-tag anyway.

    Relationship to this PR

    This PR fixes the identical bug for the array fold — its own new tests at transpiler.test.js cover x = new [a?.b][0]() and [a?.b][0]`x` (both now bail via the hardened predicate). The object fold is the direct sibling: same fold shape (single-item literal → item), same problematic parent contexts, same predicate would fix it. REVIEW.md: "Fix the whole class in the same PR — grep for every sibling site sharing the pattern."

    Fix

    One line at fold.rs:485 — add && value.can_be_inlined_from_property_access() to the condition, reusing the predicate this PR just hardened. Suggested tests alongside the new array-fold cases:

    ts.expectPrintedMin_("x = new ({f: a?.b}).f()", "x = new ({ f: a?.b }).f");
    ts.expectPrintedMin_("({f: a?.b}).f`x`", "({ f: a?.b }).f`x`");
    ts.expectPrintedMin_("x = new ({f: a?.[b]}).f()", "x = new ({ f: a?.[b] }).f");

    Severity

    Pre-existing. fold.rs is not touched by this PR and the behavior is unchanged from main. Flagging because it is the direct sibling of the fold being fixed, the output is a hard syntax error (worse than a semantic diff), and the fix is one line reusing this PR's own predicate — cheap to fold in here or file as an immediate follow-up.

Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs
…ead is_delete_target guard

The object-literal fold in maybe_rewrite_property_access returned the
value without consulting can_be_inlined_from_property_access, so
`new ({f: a?.b}).f()` minified to `new a?.b` (a hard SyntaxError) and
`({f: a?.b}).f`x`` minified to `a?.b`x``. Reuse the same predicate
there.

Drop the `!is_delete_target` clause from the array-fold guard:
`p.delete_target` is never set by the UnDelete arm so the clause was
dead, and the comment claimed `delete [x][0]` was protected when it is
not. That wiring is tracked separately.
@robobun robobun changed the title js_parser: stop the [x][0] fold from changing optional-chain / this / assignment semantics js_parser: stop the [x][0] / {f:x}.f folds from changing optional-chain / this / assignment semantics Aug 1, 2026

@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/transpiler/transpiler.test.js`:
- Around line 180-181: Remove the explanatory comments around the regression
test cases in the transpiler test, including the comment describing the sibling
predicate fold; retain only a comment containing the relevant issue URL if one
is available, otherwise leave those locations uncommented.
🪄 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: b35a8cb5-3be4-4cb8-b5dc-893afbd0de4c

📥 Commits

Reviewing files that changed from the base of the PR and between 8786d33 and 62cf6b3.

📒 Files selected for processing (3)
  • src/js_parser/fold.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/transpiler.test.js

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js_parser/visit/visit_expr.rs (1)

1101-1105: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not replace sparse-array holes with undefined.

EMissing represents an absent property. If Array.prototype[0] exists, [,][0] reads that inherited value and can invoke a getter. This rewrite always emits undefined, changing both results and side effects. Keep the indexed access for holes unless the minifier proves that indexed Array.prototype properties cannot be modified.

🤖 Prompt for 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.

In `@src/js_parser/visit/visit_expr.rs` around lines 1101 - 1105, Remove the
EMissing-to-E::Undefined rewrite in the indexed access handling around the
inlined expression. Preserve the original indexed access for sparse-array holes,
unless the minifier has an explicit proof that indexed Array.prototype
properties cannot be modified; keep ordinary inlined values unchanged.
🤖 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.

Outside diff comments:
In `@src/js_parser/visit/visit_expr.rs`:
- Around line 1101-1105: Remove the EMissing-to-E::Undefined rewrite in the
indexed access handling around the inlined expression. Preserve the original
indexed access for sparse-array holes, unless the minifier has an explicit proof
that indexed Array.prototype properties cannot be modified; keep ordinary
inlined values unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4bce009a-bcca-44b6-b838-8a1abb0098e9

📥 Commits

Reviewing files that changed from the base of the PR and between 62cf6b3 and 9e1ad64.

📒 Files selected for processing (1)
  • src/js_parser/visit/visit_expr.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.

Thanks — 62cf6b3 addressed the dead !is_delete_target guard I flagged, and the PR body now scopes the p.delete_target / is_template_tag wiring gaps as pre-existing follow-ups. No new issues found this pass. Deferring to a maintainer to sign off on the scope split and the new (0, obj.m)() emission, since this is minifier-output correctness.

What was reviewed: the merged single/multi-item fold preserves the EMissing → undefined path and the can_be_inlined_from_property_access gate; the new (0, x)() emit matches the five sibling folds (comma/??/||/&&/ternary) exactly; the assign_target guard also covers the "foo"[n] = v string-index arm and [,][0] = v; the {f: x}.f fold's existing !is_call_target/!is_delete_target/assign_target == None gates mean the new predicate only adds the optional-chain bailout there.

Extended reasoning...

Overview

Four files: src/ast/expr.rs (three new arms in can_be_inlined_from_property_access rejecting EDot/EIndex/ECall with optional_chain set), src/js_parser/fold.rs (the {f:x}.f fold now consults that predicate), src/js_parser/visit/visit_expr.rs (the [x][0] fold gains an assign_target bailout, merges its two arms, turns the multi-item debug_assert! into a real guard, and emits (0, x)() when is_call_target && has_value_for_this_in_call()), plus ~120 lines of transpiler assertions and a runtime-semantics subprocess test.

Since my last review, 62cf6b3 dropped the dead && !is_delete_target clause I flagged (it could never fire because p.delete_target is never seeded by the UnDelete arm) and moved that fact into the PR description's out-of-scope section alongside the is_template_tag gap — both are pre-existing port gaps shared with the sibling folds.

Security risks

None. This is AST-level constant folding; the change strictly narrows when an existing rewrite fires. No untrusted-input parsing, allocation, or FFI is touched.

Level of scrutiny

High — minifier semantic correctness affects every file Bun transpiles under bun run (minify_syntax defaults on). The change is mostly a tightening (added bailouts, worst case = missed optimization), but the (0, obj.m)() emission is a new output shape. I verified it matches the exact pattern at visit_binary.rs:228/372/395/417 and visit_expr.rs:1502/1530, and has_value_for_this_in_call() (EDot | EIndex) is reached only after can_be_inlined_from_property_access() has already excluded optional-chain members, so the branch only fires for plain obj.m / obj[m]. The technical observable — this becomes undefined/global instead of the temp array — is the same trade the five sibling folds already make.

I also traced the merged single/multi-item logic against the old code: [,][0] still becomes undefined (the EMissing check now runs before the predicate, whereas before it fell through from the single-item arm to the multi-item arm), int == 0 is equivalent to the old number.value() == 0.0 under the enclosing integer/range guard, and the assign_target gate correctly sits above the string-index arm so "foo"[2] = 1 no longer becomes "o" = 1.

Other factors

Test coverage is thorough: exact-output assertions for chain start/continuation across . / [] / (), the multi-item @__PURE__ path, LHS/delete/new/tagged-template positions for the optional-chain bailout, call/assign/inc/destructuring for the new guards, plus positive cases proving the fold still fires. A subprocess runtime test cross-checks eleven cases against Node semantics. My earlier feedback was addressed; no new findings from the bug hunter this run.

I'm not approving because a maintainer should ratify the scope decision — leaving delete [obj.p][0] and [obj.m][0]x`` unfixed here is reasonable (wiring p.delete_target / `is_template_tag` touches every dormant guard and all sibling folds), but that's a call for a human, not a bot.

robobun added a commit that referenced this pull request Aug 1, 2026
Two more parallel-arm sites in the same bug class:

- `BinNullishCoalescingAssign | BinLogicalOrAssign` in `visit_right_and_finish`
  (the `{}.x ??= v` HMR fold) returned `e_.right` bare with no
  `has_value_for_this_in_call()` guard. Fires without `minify_syntax`.
  `({}.x ??= obj.m)()` / `({}.x ??= obj.m)\`t\`` now emit `(0, obj.m)`.

- `substitute_single_use_symbol_in_expr`'s `ETemplate` arm lacked the
  'don't substitute into a target that could change this' check the parallel
  `ECall` arm has. `let x = obj.m; x\`t\`` no longer inlines to
  `obj.m\`t\``.

Both were pre-existing for `is_call_target` too and neither is covered by
#36730/#36734.
@Jarred-Sumner
Jarred-Sumner merged commit d76edcf into main Aug 4, 2026
54 of 56 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/40497af5/array-index-inline-optional-chain branch August 4, 2026 05:14
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…in / this / assignment semantics (oven-sh#36730)

## Problem

With `minify_syntax` on (the default for `bun run`), the `[x][0] -> x`
and `{f: x}.f -> x` folds were replacing an index/property Reference
with a bare value without fully checking the parent context. All of
these reproduce against Node on current main:

```js
var a = null;
[[a?.b]][0]?.[0].c;         // Node: TypeError   Bun: undefined    (output: a?.b.c)
new ({f: a?.b}).f();        // Node: runs        Bun: SyntaxError  (output: new a?.b)

var obj = { m() { return this === obj } };
[obj.m][0]();               // Node: false       Bun: true         (output: obj.m())

var o = { p: 1 };
[o.p][0] = 5; o.p;          // Node: 1           Bun: 5            (output: o.p = 5)
"foo"[2] = 1;               // Node: no-op       Bun: SyntaxError  (output: "o" = 1)
```

The direct form `[a?.b]?.[0].c` is hidden on main because the parse-time
"drop `?.` on a non-nullable literal" simplification clears the outer
chain first and the printer inserts `(a?.b)`; one level of indirection
(`[[a?.b]][0]?.[0].c`, `({f:[a?.b]}).f?.[0].c`) bypasses that.

## Fix

- `can_be_inlined_from_property_access` now rejects
`EDot`/`EIndex`/`ECall` whose `optional_chain` is set, so an optional
chain is never spliced onto a surrounding `?.` continuation and never
lands as a bare `new` / template-tag target.
- Both folds now route through that predicate (the `{f: x}.f` fold
previously did not).
- The `e_index` fold bails when `in_.assign_target != None`, so `[x][0]
= v`, `"s"[n] = v` and `[,][0] = v` are left as written instead of
producing `x = v` / `"s" = v` / `void 0 = v`.
- When the `e_index` fold is a call target and the inlined item is an
`EDot`/`EIndex`, it emits `(0, x)()` instead of `x()`, matching the
sibling comma/`??`/`||`/`&&`/ternary folds so `this` is not rebound.
- The multi-item path's `debug_assert!` on the predicate becomes a real
guard (reachable via a `/* @__PURE__ */ a?.()` array item).

The folds still fire for plain targets: `[a.b][0].c -> a.b.c`,
`[[y]][0]?.[0].c -> y.c`, `[y][0]() -> y()`, `({f: y}).f -> y`, `new
({f: C}).f() -> new C`. esbuild does not perform these folds at all, so
the remaining bailouts bring Bun in line with its output.

## Out of scope (tracked separately)

Two pieces of parser-context tracking were never wired up in the Rust
port, so the corresponding parent positions are not yet covered here for
plain (non-chain) items:

- `p.delete_target` is never set by the `UnDelete` arm, so `delete
[obj.p][0]` still folds to `delete obj.p`. Wiring it up turns on every
dormant `is_delete_target` guard in the visitor (bundler "cannot assign
to import", CJS-named-export deopt, enum/namespace inlining under
`delete`).
- `is_template_tag` is commented out, so `[obj.m][0]`x`` still rebinds
`this`. Wiring it up touches every sibling fold
(comma/`??`/`||`/`&&`/ternary).

Both are pre-existing and have a blast radius beyond this fold; the
optional-chain bailout already keeps `delete [a?.b][0]` and
`[a?.b][0]`x`` unfolded in the meantime.

## Verification

- `bun bd test test/bundler/transpiler/transpiler.test.js -t "property
access inlining"` passes (8 tests); the three new tests fail with `src/`
reverted to main.
- Full `transpiler.test.js` (186 pass / 0 fail) and
`bundler_minify.test.ts` (42 pass / 0 fail) are green.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 1 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 failed, 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (aa68cff)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.64ms]
(pass) Bun.Transpiler > normalizes \r\n [5.92ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely oven-sh#2746 [3.87ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [6.99ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.54ms]
(pass) Bun.Transpiler > property access inlining > works [2.17ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.49ms]
76 |     transpiledOutput: code => {
77 |       return ts.parsed(code, false, false);
78 |     },
79 | 
80 |     expectPrintedMin_: (code, out) => {
81 |       expect(ts.parsedMin(code, !out.endsWith(";\n"), false)).toBe(out);
                                                                   ^
error: expect(received).toBe(expected)

Expected: "x = [a?.b]?.[0].c"
Received: "x = a?.b.c"

      at expectPrintedMin_ (/workspace/bun/test/bu
... (truncated)

release without fix: 2 failed, 22 skipped
bun test v1.4.0-canary.1 (8786d33)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [2.42ms]
(pass) Bun.Transpiler > normalizes \r\n [0.15ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely oven-sh#2746 [0.08ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [0.14ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [0.03ms]
(pass) Bun.Transpiler > property access inlining > works [0.03ms]
(pass) Bun.Transpiler > property access inlining > works nested [0.03ms]
76 |     transpiledOutput: code => {
77 |       return ts.parsed(code, false, false);
78 |     },
79 | 
80 |     expectPrintedMin_: (code, out) => {
81 |       expect(ts.parsedMin(code, !out.endsWith(";\n"), false)).toBe(out);
                                                                   ^
error: expect(received).toBe(expected)

Expected: "x = new { f: a?.b }.f"
Received: "x = new a?.b"

      at expectPrintedMin_ (/workspace/bun/test/bundler/transpiler/transpiler.test.js:81:63)
      at <anonymous> (/workspace/bun/test/bundler/transpiler/transpiler.test.js:182:10)
(fail) Bun.Transpiler > proper
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 22 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (aa68cff)

test/bundler/transpiler/transpiler.test.js:
(pass) Bun.Transpiler > handles errors when parsing macros [5.75ms]
(pass) Bun.Transpiler > normalizes \r\n [6.06ms]
1
(pass) Bun.Transpiler > doesn't hang indefinitely oven-sh#2746 [4.03ms]
(pass) Bun.Transpiler > property access inlining > bails out with spread [7.03ms]
(pass) Bun.Transpiler > property access inlining > bails out with multiple items [2.56ms]
(pass) Bun.Transpiler > property access inlining > works [2.25ms]
(pass) Bun.Transpiler > property access inlining > works nested [2.63ms]
(pass) Bun.Transpiler > property access inlining > bails out when the array item is an optional chain [45.16ms]
(pass) Bun.Transpiler > property access inlining > bails out or strips `this` when the index is a call/assignment target [21.39ms]
(pass) Bun.Transpiler > property access inlining > preserves runtime semantics when inlining from a literal index [324.61ms]
(pass) Bun.Transpiler > property access inlining > bails out on optional
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 730ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] 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_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap 
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/ast/expr.rs                            |   5 ++
 src/js_parser/fold.rs                      |   1 +
 src/js_parser/visit/visit_expr.rs          |  36 +++++----
 test/bundler/transpiler/transpiler.test.js | 118 +++++++++++++++++++++++++++++
 4 files changed, 145 insertions(+), 15 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                        reads  edits  tests
src/ast/expr.rs                                 3      3      0
src/js_parser/fold.rs                           2      1      0
src/js_parser/visit/visit_expr.rs               8      4      0
test/bundler/transpiler/transpiler.test.js      5     14      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

2 participants