Skip to content

Apply defines to computed string-literal member accesses - #33062

Closed
robobun wants to merge 3 commits into
mainfrom
farm/cb2baa7b/define-computed-member-access
Closed

robobun wants to merge 3 commits into
mainfrom
farm/cb2baa7b/define-computed-member-access

Conversation

@robobun

@robobun robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

bun build --target=browser leaves process.env["NODE_ENV"] verbatim unless --minify is also passed, while the dot form process.env.NODE_ENV is always substituted. The bracket form is what codemods, env-helper wrappers, and TS index signatures produce, and the leftover process reference is a ReferenceError in a browser. The same source therefore behaves differently between minified and unminified browser bundles.

console.log(process.env["NODE_ENV"], process.env.NODE_ENV);
// bun build --target=browser          -> console.log(process.env["NODE_ENV"], "development")
// bun build --target=browser --minify -> both inlined

esbuild substitutes both forms regardless of minification.

Cause

The define table lookup only lives in the EDot visitor (e_dot). A computed access only reaches it through the a["b"] => a.b rewrite at the top of e_index, which is gated on minify_syntax. is_dot_define_match already handles EIndex intermediates (so process["env"].NODE_ENV worked); only the outermost EIndex was never looked up.

The same missing lookup affects user --define (--define 'FOO.BAR="hi"' misses FOO["BAR"]) and --drop:

  • --drop=console.log does not remove console["log"]("x")
  • --drop=Bun.inspect does not remove Bun.inspect["table"](), because e_index also did not propagate the drop flag to its target the way e_dot does

Fix

In e_index, when the index is a string literal, consult the dots define table before the minify_syntax rewrite: substitute on a non-valueless match, or set the --drop flag on a valueless one. Also propagate property_access_for_method_call_maybe_should_replace_with_undefined through the target visit, matching e_dot. This mirrors esbuild, which checks defines on EIndex ahead of its minify rewrite.

E::Index has no can_be_removed_if_unused / call_can_be_unwrapped_if_unused fields (unlike E::Dot), so the DCE flag copies from the e_dot path are intentionally not ported; doing so needs new fields on E::Index and only affects a minification pessimization for the no-side-effect globals table, not correctness.

The optional-chain forms (a?.["b"], the default/DefineOptionalChain todo test) remain a separate gap, tracked by #21084 / #28693.

Verification

New tests fail on the released binary and pass with the fix:

bun bd test test/bundler/transpiler/transpiler.test.js -t "computed string-literal"
bun bd test test/bundler/bundler_edgecase.test.ts -t NodeEnvComputedPropertyAccess
bun bd test test/bundler/bundler_drop.test.ts

Full bundler_edgecase, bundler_env, bundler_browser, bundler_minify, transpiler, esbuild/default, and esbuild/dce suites pass.

`process.env["NODE_ENV"]` (and any user `--define`d dot path spelled
with brackets) was only substituted when `--minify` was passed, because
the `a["b"]` => `a.b` rewrite that routes `EIndex` into the `e_dot`
define lookup is gated on `minify_syntax`. The same gap made
`--drop=console.log` miss `console["log"]()` and `--drop=a` miss
`a["b"]["c"]()`.

Add the define lookup (value substitution plus the `--drop` flag) to
`e_index` for a string-literal index, and propagate the `--drop` flag
through the index target visit, mirroring `e_dot`. esbuild checks
defines on `EIndex` before the minify rewrite for the same reason.
@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:51 AM PT - Jun 29th, 2026

❌ @robobun, your commit 0dda155 has 3 failures in Build #66785 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33062

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

bun-33062 --bun

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 92a5085a-4391-4d5e-9b36-ba22bd4d14c5

📥 Commits

Reviewing files that changed from the base of the PR and between 4da35f6 and 0dda155.

📒 Files selected for processing (2)
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/bundler_drop.test.ts

Walkthrough

The JS parser's e_index handler is extended to apply --define and --drop substitutions to computed string-literal property accesses (e.g., obj["key"]), mirroring the existing dot-access logic. The property_access_for_method_call_maybe_should_replace_with_undefined flag is also propagated into recursive target visits. New tests cover transpiler defines, bundler drop, and process.env edge cases.

Computed string-literal define/drop substitution

Layer / File(s) Summary
e_index define substitution and flag propagation
src/js_parser/visit/visit_expr.rs
In e_index, consults p.define.dots to match and replace computed string-literal accesses via p.value_for_define, sets method_call_must_be_replaced_with_undefined when applicable, and propagates property_access_for_method_call_maybe_should_replace_with_undefined into recursive target visits.
Tests: transpiler defines, bundler drop, and edge cases
test/bundler/transpiler/transpiler.test.js, test/bundler/bundler_drop.test.ts, test/bundler/bundler_edgecase.test.ts
Verifies define substitution for process.env["NODE_ENV"]-style accesses in the transpiler, drop propagation for console["log"] and Bun["inspect"]["table"](), and that bracket-notation process.env lookups resolve to the same value as dot-notation.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change to computed string-literal member access defines.
Description check ✅ Passed The description clearly explains the change and includes verification steps, covering the template's required intent.
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.

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

@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 — the new e_index define lookup mirrors e_dot faithfully and the test coverage is solid — but this is core transpiler output semantics, so worth a quick human pass.

Extended reasoning...

Overview

This PR adds ~40 lines to src/js_parser/visit/visit_expr.rs so that computed string-literal member accesses (process.env["NODE_ENV"]) consult the define table the same way dot accesses (process.env.NODE_ENV) already do, independent of --minify. It also propagates the property_access_for_method_call_maybe_should_replace_with_undefined flag through the e_index target visit so --drop=a.b catches a.b["c"](). Three test files gain coverage for define substitution, --drop, and the NODE_ENV browser-target case.

I compared the new block against e_dot (visit_expr.rs:1398-1436) and is_dot_define_match (p.rs:6694-6727): the logic is a near-verbatim port — same assign-target guard, same valueless/drop branching, same break on first match — with the documented omission of the can_be_removed_if_unused / call_can_be_unwrapped_if_unused flag copies that E::Index lacks fields for. The is_utf8() guard matches the existing EIndex arm in is_dot_define_match, and the early return on substitution prevents double-handling when minify's a["b"] => a.b rewrite would have fired afterward. Optional chains remain correctly excluded via the existing optional_chain.is_some() check in is_dot_define_match.

Security risks

None. This is transpiler output shaping (define substitution / --drop); no auth, crypto, fs, or network surface.

Level of scrutiny

Moderate-to-high. The change is small and pattern-matched against proven code, but it lives in the core JS visitor and silently alters generated output for any code using bracket-notation property access that happens to collide with a define key. A subtle mistake here would manifest as wrong runtime values in user bundles rather than a build error, which argues for a human sanity check even though the diff reads cleanly.

Other factors

  • No CODEOWNERS for src/js_parser/.
  • New tests cover substitution, assign-target exclusion, non-literal/non-matching keys, and three nested --drop shapes; PR description reports the full bundler_* / transpiler / esbuild/* suites pass.
  • No prior reviews on the PR; only the robobun build comment.

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status. The diff is green on every lane that exercises it; the remaining red is infrastructure and two pre-existing upstream issues, none of it caused by this change.

The review finding was real and is fixed. claude's line comment on the target visit was correct: propagating the --drop flag into the e_index target visit let a call nested in the computed index steal it, so console[lvl()]("x") under --drop=console emitted console[undefined]("x"). Fixed in 0dda155 by stashing the flag across the index visit; drop/ComputedDynamicIndex is the regression test. Details in the resolved review thread.

BuildKite. Build 66730 (the original fix) had 280 test jobs pass, covering every linux glibc, ASAN, Windows, and darwin 14 lane. Its only failures:

Build 66785 against 0dda155 is final: 282 jobs passed, 4 failed. None of the failing files are in this diff.

  • alpine 3.23 x64 / x64-baseline: the same test-net-connect-memleak.js flake, for the third time on this PR. It also appears on 4 of the last 6 unrelated PR builds, matching the rate Quarantine test-net-connect-memleak on linux-x64-musl #33045 documents.
  • darwin 14 aarch64: test/js/web/websocket/autobahn.test.ts. The Autobahn service container never starts: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64). That is a Docker image and agent architecture mismatch; no tests ran.
  • darwin 14 aarch64: test/regression/issue/20965.test.ts, a Bun.serve streaming-abort leak test that timed out at 90s on one shard and passed on every other lane in the same build.

The GitHub Actions Format check is red, but not from this diff. autofix-ci's staged changes are exactly docs/guides/util/base64.mdx and docs/runtime/web-apis.mdx, which landed unformatted on main in 033e0b1 (#33040) after this branch forked; the autofix commit cannot be cherry-picked onto a head that predates them (could not apply 9d918ca6). Every branch older than that commit hits it. All four files this PR touches pass prettier and cargo fmt.

A parser change produces the same AST on every platform, so a failure confined to one musl GC-timing test, one darwin artifact-download step, and two docs files outside this diff cannot come from this change. I already spent the one retrigger; this is ready for review.

Comment thread src/js_parser/visit/visit_expr.rs
In `e_index`, the target visit can set the parser-global
`method_call_must_be_replaced_with_undefined` for the enclosing `e_call`
to consume. Visiting the index expression next left that flag live, so a
call nested in the index consumed it instead: `console[lvl()]("x")` with
`--drop=console` emitted `console[undefined]("x")`, a runtime TypeError.

Stash and clear the flag across the index visit and restore it after, so
the enclosing call is the one that sees it. `e_dot` has no index
sub-expression, which is why it never had this problem.

@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 fix in 0dda155 correctly addresses the flag-leak I flagged earlier and the regression test covers it — looks right to me, but since this changes code-gen semantics in the core parser visit pass (every a["b"] now hits the define table), I'd like a human to sign off.

Extended reasoning...

Overview

This PR extends e_index in src/js_parser/visit/visit_expr.rs to consult the --define/--drop table for computed string-literal member accesses (a["b"]), mirroring the existing e_dot path. It also propagates the property_access_for_method_call_maybe_should_replace_with_undefined flag through the target visit so --drop=a.b removes a.b["c"](), and stashes/restores the parser-global method_call_must_be_replaced_with_undefined across the index sub-expression visit. Three test files add coverage for define substitution, drop propagation, and the process.env["NODE_ENV"] browser-target case.

Follow-up on prior review

I previously flagged that propagating the drop flag into the target visit let it leak into the index visit, producing console[undefined]("x") for console[lvl()]("x") under --drop=console. Commit 0dda155 fixes this by stashing the flag with core::mem::replace immediately before p.visit_expr(&mut e_.index) and restoring it immediately after — the restore happens before any of the arm's early returns, and the private-identifier arm doesn't visit the index, so the scoping is sound. The new drop/ComputedDynamicIndex test exercises exactly this path. The bug-hunting pass on the fixed revision found nothing.

Security risks

None. This is a build-time AST transformation with no auth, network, filesystem, or crypto surface. The substituted values come from the user's own --define/--drop configuration.

Level of scrutiny

High. This is the core JS parser visit pass — every file Bun bundles or transpiles flows through e_index, and the change alters user-facing output semantics (code that previously emitted process.env["X"] verbatim now inlines it). The interaction between the per-visit ExprIn flag and the parser-global method_call_must_be_replaced_with_undefined is subtle enough that the first revision shipped a real regression; while the follow-up fix looks correct and is well-tested, I don't have high enough confidence to say a human shouldn't look at a code-gen behavior change in this layer.

Other factors

Test coverage is thorough (transpiler-level, bundler-level, and the targeted regression test), CI is green on every relevant lane, and there is no CODEOWNER for src/js_parser/. The change is small and closely mirrors the established e_dot pattern, so the remaining review burden should be light.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Another report of this came in today, framed around drop: ["console"] specifically: console["log"]("x") survives the bundle unless --minify is also passed, while console.log("x") and console.log?.("x") are dropped. esbuild 0.28.1 drops all three without minify.

Same root cause as this PR (the e_index visitor neither consults the dot-define table nor propagates the drop flag to its target) and covered by drop/ComputedFunctionCall here. Reproduced on origin/main fc865b3 and confirmed the fix in this branch handles it.

@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #40857. That PR looks up defines for a computed string index in e_index (process.env["NODE_ENV"], console["log"]() with --drop), passes the drop flag to the index target, and hides it while the index is visited so console[lvl()]("x") is not dropped in the wrong place. It also matches a?.["b"] and process?.env?.["SOME-VAR"].

The tests from this PR pass on that branch: drop/ComputedFunctionCall, drop/ComputedBecomesUndefined, drop/ComputedBecomesUndefinedNested1, drop/ComputedBecomesUndefinedNested2, drop/ComputedDynamicIndex, edgecase/NodeEnvComputedPropertyAccess, and the Bun.Transpiler case define matches computed string-literal member access. Closing in favor of #40857.

@robobun robobun closed this Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant