Skip to content

bundler: hoist object/array --define values to a shared var - #35958

Closed
robobun wants to merge 11 commits into
mainfrom
farm/6f67858c/hoist-object-define
Closed

robobun wants to merge 11 commits into
mainfrom
farm/6f67858c/hoist-object-define

Conversation

@robobun

@robobun robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

What

When --define is given an object or array literal, emit it once as var define_KEY_default = {...} at module scope and substitute every reference with that identifier, instead of inlining a fresh {...} at each use site.

Repro

// entry.mjs, built with: --define 'CFG={"k":1,"nested":{"x":2}}'
const a = CFG, b = CFG;
console.log(a === b, a.nested === b.nested);   // real global: true true
a.k = 99; console.log(b.k);                    // real global: 99

Before:

var a = { k: 1, nested: { x: 2 } };
var b = { k: 1, nested: { x: 2 } };            // fresh copy per site
// prints: false false / 1

After (matches esbuild):

var define_CFG_default = { k: 1, nested: { x: 2 } };
var a = define_CFG_default;
var b = define_CFG_default;
// prints: true true / 99

Cause

value_for_define in p.rs returned Expr { data: define_data.value, loc } for anything that wasn't an identifier or string. For EObject/EArray that meant the same AST node was printed inline at every substitution site, so the output contained N independent literals. esbuild instead routes compound define values through its --inject path as a synthetic <define:KEY> module and substitutes a reference to its default export.

Fix

  • Define::insert assigns an injected_define_index to any DefineData whose value is EObject/EArray and records it on Define.injected.
  • prepare_for_visit_pass pre-declares one module-scope symbol per injected define (define_<key>_default, hash-suffixed when the renamer isn't in use).
  • value_for_define returns EIdentifier(ref) for injected defines and records the usage.
  • After the visit, parse_entry.rs emits var define_X_default = {...} as its own before part (marked can_be_removed_if_unused) for each injected define that was actually referenced.

This also fixes a latent bug where assigning to an object-valued define printed {"k":1} = x;, which is a runtime SyntaxError.

Cross-module identity (a single object shared across every bundled file, esbuild's <define:X> virtual module) is not included; it needs the --inject infrastructure which bun has not ported. Each module now materializes the literal once instead of once per reference.

Verification

USE_SYSTEM_BUN=1 bun test bundler/esbuild/extra.test.ts -t DefineObjectIdentity   # 3 fail
bun bd test bundler/esbuild/extra.test.ts -t DefineObjectIdentity                 # 3 pass

Also ran the full extra.test.ts (223 pass), transpiler.test.js (182 pass), bundler_env.test.ts, bundler_jsx.test.ts, and the bun-build-api.test.ts define cases.

Fixes #21319
Fixes #21312


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

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

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [499.88ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [131.11ms]
(pass) bundler > extra/JSXEscaping2 [152.24ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [431.19ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [396.61ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [417.30ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [423.55ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [374.85ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [466.56ms]
(pass) bundler > extra/RemoveASMDirective [448.24ms]
(pass) bundler > extra/ImportOrder1 [386.23ms]
(pass) bundler > extra/ImportOrder2 [441.48ms]
(pass) bundler > extra/CyclicImport1 [383.81ms]
(pass) bundler > extra/TypeofRequireESM [1433.45ms]
(pass) bundler > extra/CJSExport1 [919.59ms]
(pass) bundler > extra/CJSExport2 [428.0
... (truncated)

release without fix: 12 skipped
bun test v1.4.0-canary.1 (9c4f9f976)

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [17.96ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [3.67ms]
(pass) bundler > extra/JSXEscaping2 [3.85ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [19.66ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [18.14ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [17.73ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [23.71ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [18.77ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [24.21ms]
(pass) bundler > extra/RemoveASMDirective [19.47ms]
(pass) bundler > extra/ImportOrder1 [18.95ms]
(pass) bundler > extra/ImportOrder2 [28.79ms]
(pass) bundler > extra/CyclicImport1 [18.59ms]
(pass) bundler > extra/TypeofRequireESM [42.13ms]
(pass) bundler > extra/CJSExport1 [22.73ms]
(pass) bundler > extra/CJSExport2 [18.42ms]
(pass) bundler > extra/CJSExport3 [20.11ms]
(pass) bundler > extra/CJSExport4 [23.32ms]
(pass) bundler > extra/CJSExport5 [24.08ms]
(pass) bundler > extra/CJSExport6 [21.66m
... (truncated)
passes on PR (with fix)
ASAN with fix: 12 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/esbuild/extra.test.ts
bun test v1.4.0 (9c4f9f976)

test/bundler/esbuild/extra.test.ts:
(pass) bundler > extra/FileAsDirectoryBreak [471.72ms]
(todo) bundler > extra/PathWithQuestionMark
(pass) bundler > extra/JSXEscaping1 [103.74ms]
(pass) bundler > extra/JSXEscaping2 [80.14ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers1 [373.93ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers2 [411.65ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers3 [404.21ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers4 [399.03ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers5 [362.78ms]
(pass) bundler > extra/ArbitraryModuleNamespaceIdentifiers6 [394.97ms]
(pass) bundler > extra/RemoveASMDirective [408.94ms]
(pass) bundler > extra/ImportOrder1 [384.28ms]
(pass) bundler > extra/ImportOrder2 [408.29ms]
(pass) bundler > extra/CyclicImport1 [403.53ms]
(pass) bundler > extra/TypeofRequireESM [1339.66ms]
(pass) bundler > extra/CJSExport1 [383.73ms]
(pass) bundler > extra/CJSExport2 [701.54
... (truncated)

release with fix: 12 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 739ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] 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_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/workspace/bun/src/bunfig)
�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compili
... (truncated)
diff hotspot
src/bundler/defines.rs             |  4 ++
 src/bundler/options.rs             |  2 +
 src/js_parser/lib.rs               | 35 ++++++++++++++-
 src/js_parser/p.rs                 | 45 +++++++++++++++++++
 src/js_parser/parse/parse_entry.rs | 40 +++++++++++++++++
 src/js_parser/visit/visit_expr.rs  | 17 +++++++
 test/bundler/esbuild/extra.test.ts | 92 ++++++++++++++++++++++++++++++++++++++
 7 files changed, 234 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                reads  edits  tests
src/bundler/defines.rs                  2      4      0
src/bundler/options.rs                  1      2      0
src/js_parser/lib.rs                    5     10      0
src/js_parser/p.rs                     11     10      0
src/js_parser/parse/parse_entry.rs      4      4      0
src/js_parser/visit/visit_expr.rs       7      5      0
test/bundler/esbuild/extra.test.ts      3      5      0

When --define is given an object or array literal, emit it once as
`var define_KEY_default = {...}` at module scope and substitute every
reference with that identifier, instead of inlining a fresh literal at
each use site.

This matches esbuild's output and makes two references to the same
define compare equal (a === b), lets mutations be visible across
references, and stops a define referenced in a hot loop from allocating
per iteration. It also fixes the case where assigning to an
object-valued define previously printed an invalid `{...} = x`
expression.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Hoisted define identity

Layer / File(s) Summary
Define storage and propagation
src/js_parser/lib.rs, src/bundler/defines.rs, src/bundler/options.rs
Define data tracks injected values, object and array literals are stored once, and bundle options initialize and copy them.
Injected symbol resolution
src/js_parser/p.rs
The parser predeclares generated symbols and resolves injected define references to identifiers.
Removable define emission and validation
src/js_parser/parse/parse_entry.rs, test/bundler/esbuild/extra.test.ts
Used injected defines are emitted as removable variable parts, with tests covering bundled and non-bundled identity.
Delete substitution handling
src/js_parser/visit/visit_expr.rs, test/bundler/esbuild/extra.test.ts
Renamed identifier operands in delete expressions are wrapped to preserve strict-mode behavior, with corresponding output tests.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation matches #21319 and #21312 by hoisting JSON define values, sharing references, and avoiding duplicated full-object output.
Out of Scope Changes check ✅ Passed The added delete-handling and tests are consistent with the stated fix and do not appear unrelated to the linked objectives.
Title check ✅ Passed The title clearly summarizes the main change: hoisting object/array --define values into a shared variable.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses different headings than the template.

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

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: tests pass locally (fail-before/pass-after). Diff is green.

USE_SYSTEM_BUN=1 bun test bundler/esbuild/extra.test.ts -t DefineObjectIdentity   # 3 fail
bun bd test bundler/esbuild/extra.test.ts -t DefineObjectIdentity                 # 3 pass

Linked #21319 and #21312 (output now matches esbuild). Not linking #21210: that issue is about primitive-valued defines on the LHS of an assignment (2 = x), which this PR doesn't touch; only the object/array case becomes a valid assignable identifier as a side effect.

Addressed review in bea1e6e + bc1585c: the Op::UnDelete visitor now wraps the operand as (0, x) whenever define substitution yields an identifier the source didn't name (dot-define and bare-identifier cases alike), comparing the symbol name before vs after the visit so source-written delete x / delete unbound stay bare. This also fixes the pre-existing case where --define process.env.X=someGlobal + delete process.env.X emitted delete someGlobal (a strict-mode SyntaxError). Covered by extra/DefineObjectDeleteTarget and extra/DefineObjectDeleteBareIdentifier.

CI build 82612: none of the failures touch this diff.

Ready for review.

@robobun

robobun commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:19 PM PT - Jul 26th, 2026

❌ @robobun, your commit 9c4f9f9 has 2 failures in Build #82612 (All Failures):

  • test/cli/run/no-orphans.test.ts - code 1 on 🍎 14 x64
  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    ❌ bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    ❌ bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    ❌ bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    ❌ bun-linux-x6472.48 MB71.95 MB+544.0 KB
    ❌ bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    ❌ bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    ❌ bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    ❌ bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    ❌ bun-freebsd-x6483.09 MB82.56 MB+544.0 KB
    ❌ bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    ❌ bun-windows-x6480.26 MB79.70 MB+577.0 KB
    ❌ bun-windows-aarch6470.86 MB70.34 MB+539.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35958

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

bun-35958 --bun

Comment thread src/js_parser/lib.rs Resolved
Comment thread src/js_parser/lib.rs Resolved
Comment thread src/js_parser/lib.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/parse/parse_entry.rs Resolved
@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. bun --define with JSON data performs inefficient substitution ( duplicates values on every usage ) #21319 - Directly reports the inefficient duplication of JSON/object define values at every usage site instead of hoisting to a shared var
  2. bun.build --define with JSON values prints full objects in output #21312 - Reports that --define with JSON values inlines full object literals in output instead of resolving them properly
  3. bun.build --define should not substitution a variable that is assigned to with a substitution that is not assignable #21210 - Reports that assigning to an object-valued define produces invalid JS like {"k":1} = x;; hoisting to a var makes the LHS an assignable identifier

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #21319
Fixes #21312
Fixes #21210

🤖 Generated with Claude Code

@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 `@src/js_parser/lib.rs`:
- Around line 514-517: Add a dedicated SAFETY comment immediately before the
unsafe impl Sync for InjectedDefine, documenting why synchronized access is
sound for InjectedDefine::value; keep the existing Send comment attached only to
the unsafe impl Send.
🪄 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: af89bf53-ff7a-4aa3-80ae-13263def4110

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and b810537.

📒 Files selected for processing (6)
  • src/bundler/defines.rs
  • src/bundler/options.rs
  • src/js_parser/lib.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • test/bundler/esbuild/extra.test.ts

Comment thread src/js_parser/lib.rs Resolved

@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/js_parser/p.rs:6350-6359 — The new injected-define path returns EIdentifier(ref_) without accounting for delete targets. With --define 'process.env.CFG={"x":1}', source delete process.env.CFG now emits delete define_process_env_CFG_default, which is an early SyntaxError in strict mode (all ESM output) — before this PR the same input emitted the valid delete { x: 1 }. Either skip the injected path for delete targets and inline the literal, or add a post-visit (0, x) wrap in the Op::UnDelete arm mirroring UnTypeof.

    Extended reasoning...

    What the bug is

    When a --define key is given an object/array value and the source contains delete <that key>, the bundler now emits delete <bare-identifier>. In strict mode — which every ES module is, and Format::Esm is the default output format — delete <IdentifierReference> is an early SyntaxError per ES §13.5.1.1. The bundled module fails to parse at load time.

    Code path

    Walk delete process.env.CFG with --define 'process.env.CFG={"x":1}':

    1. Parse — parse_prefix.rs:408-414: the delete operand is EDot, so UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS is set on the unary node.
    2. Visit the unary — visit_expr.rs:1221-1223: Op::UnDelete visits its operand with ExprIn::default(), so in_.assign_target == None. Unlike the UnTypeof arm just above (lines 1179-1196), there is no post-visit fixup that wraps a newly-appeared identifier in (0, x).
    3. Visit the e_dot operand — visit_expr.rs:1347: the dot-define substitution gates only on in_.assign_target == None (which it is), so it calls value_for_define.
    4. This PR's new code — p.rs:6350-6359: injected_define_index is Some, so it returns EIdentifier(ref_) unconditionally. The is_delete_target parameter is ignored on this path.
    5. Print — js_printer/lib.rs:4102: the printer's protective (0, x) wrap fires only when !e.flags.contains(WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS). That flag is set (step 1), so the guard is false and the identifier prints bare.

    Output: delete define_process_env_CFG_default → strict-mode SyntaxError.

    Why existing code doesn't prevent it

    The printer guard at js_printer/lib.rs:4102 was designed for the reverse case: preventing delete (0, x) from being simplified to delete x. It trusts the WAS_ORIGINALLY_... flag to mean "the source already had a valid delete target here, leave it alone". That assumption breaks when the visitor swaps an EDot for an EIdentifier while the parse-time flag remains set. The UnTypeof arm has an explicit post-visit id_before/id_after check for exactly this class of substitution; UnDelete does not.

    Regression

    Before this PR, value_for_define fell through to the tail Expr { data: value, loc } and returned the EObject inline, producing delete { x: 1 }. delete on a non-Reference is valid strict-mode JS (evaluates to true). So this is a valid → syntactically-invalid output regression: any bundle that contains delete <object-valued-dot-define> now fails to load. (The identifier-key case — --define 'CFG={..}' + delete CFG — was already broken pre-PR because the source operand was an identifier and the flag was set anyway; but object/array dot defines were previously safe and are now broken.)

    Fix

    Two options:

    • In value_for_define (p.rs:6350), skip the injected path when the expression is a delete target and fall through to inline the literal — restoring the pre-PR delete { x: 1 } behaviour. Caveat: the is_delete_target argument is computed from p.delete_target, which is not currently set in the Op::UnDelete visitor arm (grep shows it's only assigned at visit_expr.rs ~911/1026 during e_index→e_dot conversion), so this alone won't fire on the direct delete a.b.c path — you'd also need to set p.delete_target before visiting the operand in the UnDelete arm.
    • Or, more robustly, mirror the UnTypeof post-visit fixup (visit_expr.rs:1179-1196) in the Op::UnDelete arm: if the operand was not an identifier before visiting but is one after, wrap it in (0, x). That produces delete (0, define_process_env_CFG_default), which is valid strict-mode JS and preserves the identity/hoisting benefit of this PR.

    Repro

    echo 'export {}; delete process.env.CFG;' > a.mjs
    bun build --define 'process.env.CFG={"x":1}' a.mjs
    # output contains: delete define_process_env_CFG_default
    # → SyntaxError: Delete of an unqualified identifier in strict mode

Comment thread src/js_parser/p.rs Resolved
Also propagate AllocError from ensure_valid_identifier instead of
catching it.
Comment thread src/js_parser/visit/visit_expr.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved
Compare the operand's symbol name before vs after the visit instead of
only checking whether the tag became EIdentifier. This covers
`delete CFG` (sloppy source) where the pre-visit operand is already an
identifier that gets replaced by the hoisted define's identifier, while
leaving source-written `delete x` / `delete unbound` untouched.
Comment thread src/js_parser/visit/visit_expr.rs Resolved

@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/esbuild/extra.test.ts`:
- Around line 742-761: Update the DefineObjectDeleteBareIdentifier test
configuration to execute the bundled output and assert the expected stdout: true
for delete CFG, true for delete ARR, and false for delete keep. Preserve the
existing onAfterBundle regex checks, using the sibling DefineObjectDeleteTarget
test’s run configuration as the model.
🪄 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: 80107e7c-8e5e-4931-a252-a629b21181fc

📥 Commits

Reviewing files that changed from the base of the PR and between b810537 and 72bf8a9.

📒 Files selected for processing (5)
  • src/js_parser/lib.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/esbuild/extra.test.ts

Comment thread test/bundler/esbuild/extra.test.ts Resolved

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

Both earlier findings (OOM catch-default in prepare_for_visit_pass, and the bare-identifier delete CFG sibling case) are addressed and covered by tests. No further issues found, but this touches the core define-substitution path across the parser/visitor/part-emission and adds new unsafe impl Send/Sync, so it's worth a human look — particularly the per-module (not cross-module) hoisting tradeoff called out in the description.

What was reviewed:

  • Define::insert / InjectedDefine — index assignment happens for both identifier and dot defines; for_worker clones injected.
  • value_for_define — falls through to the old inline path when injected_define_refs is empty (e.g. no visit pass), so no regression there.
  • Op::UnDelete name-compare — verified source-written delete x stays unwrapped (name matches after find_symbol); the wrap now also fires for pre-existing identifier→identifier defines, which is the intended strict-mode fix.
  • parse_entry.rs — parts are gated on use_count_estimate > 0 and marked can_be_removed_if_unused, so unused defines don't bloat output.
Extended reasoning...

Overview

This PR changes how --define handles object/array values in the bundler: instead of inlining a fresh literal at every substitution site, it declares one var define_KEY_default = {...} per module and substitutes an identifier reference. Changes span src/js_parser/lib.rs (new InjectedDefine type, Define.injected field, DefineData.injected_define_index), src/js_parser/p.rs (symbol pre-declaration in prepare_for_visit_pass, new branch in value_for_define), src/js_parser/parse/parse_entry.rs (emit the var part per referenced define), src/js_parser/visit/visit_expr.rs (wrap delete operands that become identifiers via substitution), and src/bundler/{defines,options}.rs (field plumbing). Five new itBundled tests exercise identity, dot-defines, no-bundle mode, and both delete variants with runtime execution.

Security risks

None identified. Define values are parsed once at startup by the existing parse_env_json path; this PR only changes where the resulting AST node is emitted. The new unsafe impl Send/Sync for InjectedDefine mirrors the existing DefineData impls with the same rationale (immutable process-lifetime AST-store pointers, read-only after init) and now carries per-impl SAFETY comments.

Level of scrutiny

Medium-high. This is the bundler's define-substitution path — it runs on every parsed file when --define is set, and the Op::UnDelete change affects every delete <expr> regardless of defines. The change is well-scoped and well-tested, but it's not mechanical: it introduces new module-scope symbol declaration, a new Part-emission site (which interacts with tree-shaking via can_be_removed_if_unused / declared_symbols), and a name-comparison heuristic in the delete visitor. The PR also explicitly ships a partial esbuild-parity design (per-module hoisting rather than a cross-module <define:X> virtual module) — a maintainer should confirm that intermediate step is acceptable.

Other factors

Two prior review rounds from this bot were addressed in bea1e6e/bc1585c/9c4f9f9 (OOM propagation, the bare-identifier delete sibling, and running the delete test under sloppy-mode iife/node). The clippy SAFETY-comment failure was fixed. Test coverage is solid: all five new tests run: the bundle and assert stdout, and the identity test additionally counts literal occurrences in the output. The Op::UnDelete wrap now also applies to identifier→identifier defines (--define X=someGlobal + delete X), which is a behavior change beyond the linked issues but is correctly framed as a strict-mode-SyntaxError fix. CI's only hard failure is the binary-size gate against a stale baseline (per the author's note), unrelated to this diff.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-26, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. The linked issue (#21312) stays open. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants