Skip to content

js_parser: set p.delete_target before visiting the delete operand - #36734

Open
robobun wants to merge 11 commits into
mainfrom
farm/8bbbabde/js-parser-delete-target
Open

js_parser: set p.delete_target before visiting the delete operand#36734
robobun wants to merge 11 commits into
mainfrom
farm/8bbbabde/js-parser-delete-target

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

The UnDelete visit arm never wrote p.delete_target, so every is_delete_target check derived from it (in e_identifier, e_dot, e_index, e_import_meta, handle_identifier, and maybe_rewrite_property_access) was permanently false. The port carried the field and all the guards over from esbuild but dropped the one assignment that makes them reachable.

With minify_syntax on (the default for bun run):

// node prints 1; bun folds to `delete obj.p` and prints undefined
var obj = { p: 1 }; delete [obj.p][0]; console.log(obj.p);
$ echo 'enum E { A = 1 }; x = delete E.A' | bun build --no-bundle /dev/stdin
x = delete 1 /* A */;                  # esbuild keeps `delete E.A`

$ echo 'import * as ns from "./m.mjs"; delete ns.x' | bun build /dev/stdin
# no error; esbuild: Cannot assign to import "x"

$ echo 'exports.a = 1; delete exports.a' | bun build /dev/stdin
var $a = 1; delete $a; export { $a as a };   # should stay wrapped CJS

$ echo 'x = delete FOO' | bun build --no-bundle --define FOO=1 /dev/stdin
x = delete 1;                          # esbuild keeps `delete FOO`

Fix

Op::UnDelete now sets p.delete_target = e_.value.data before visiting the operand, mirroring esbuild's p.deleteTarget = e.Value.Data. This activates every existing is_delete_target guard.

Three bun-specific rewrite sites that produce a value where a property reference is required had no guard of their own and are now gated on !is_delete_target && assign_target == None (and !is_call_target where receiver identity matters):

  • the "foo"[n] -> "o" / [x][n] -> x folds in e_index. delete [obj.p][0] previously became delete obj.p; [y][0] = 5 became y = 5; [o.m][0]() became o.m() (wrong receiver).
  • the "str".length -> n fold in maybe_rewrite_property_access. delete "abc".length folded to delete 3 (true instead of false/TypeError).
  • the import.meta.{main,hot,dir,file,path,url} and import.meta.hot.<method> inlines in maybe_rewrite_property_access. delete import.meta.hot / delete import.meta.hot.accept became delete undefined (strict-mode SyntaxError); delete import.meta.main under --format=cjs became (delete require.main) == module.

RuntimeTranspilerCache version bumped since the runtime transpiler enables minify_syntax.

Not changed

  • delete (0, a.b) / delete (true ? a.b : x) / ?? || &&: bun re-wraps these at print time via UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS (set in parse_prefix.rs, consumed at src/js_printer/lib.rs:4004) rather than esbuild's visit-time before/after check; output and runtime behaviour already match esbuild. Tests added to lock this in.
  • delete process.env.NODE_ENV with a string --define: esbuild also substitutes to delete "development"; the port matches the reference here.
  • tagged-template tags ([o.m][0]`x` ): the fourth reference-observable position, but bun has no p.template_tag field yet (TODO stubs at visit_expr.rs:1044/1443). js_parser: fold property access on multi-property object literals #36599 introduces that state; the && !is_template_tag term can be appended once it lands.

Verification

  • bun bd test test/bundler/transpiler/transpiler.test.js -t "property access inlining" (5 new tests; fail on main)
  • bun bd test test/bundler/transpiler/assign-to-import.test.ts (3 new cases; fail on main)
  • bun bd test test/bundler/bundler_cjs2esm.test.ts -t Delete (2 new tests; fail on main)
  • Full transpiler.test.js (188 pass), bundler_minify.test.ts (42 pass), bundler_cjs2esm.test.ts (25 pass), esbuild/default.test.ts (151 pass), esbuild/dce.test.ts (78 pass), bundler_edgecase.test.ts (117 pass).

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

fails on main (without fix)
ASAN without fix: 10 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/bundler_cjs2esm.test.ts" test/bundler/transpiler/assign-to-import.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (470e769df)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [782.46ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [403.35ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [374.77ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [355.67ms]
(pass) bundler > cjs2esm/ExportsFunction [364.68ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [362.03ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [371.64ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [553.88ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [554.94ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [417.47ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [432.03ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrapping [403.27ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrappi
... (truncated)

release without fix: 22 skipped
bun test v1.4.0-canary.1 (60d13f7f3)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [20.19ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [10.91ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [9.71ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [8.48ms]
(pass) bundler > cjs2esm/ExportsFunction [8.35ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [8.89ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [8.41ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [11.83ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [11.84ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [9.60ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [11.06ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrapping [9.05ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrapping2 [21.46ms]
(pass) bundler > cjs2esm/ModuleExportsRenamingNoDeopt [8.92ms]
(pass) bundler > cjs2esm/ModuleExportsRenamingAssignDeOpt [8.39ms]
(pass) bundler > cjs2esm/ModuleExportsRenamingAssignExportsDeOpt [8.57ms]
(pass) bundler > cjs2esm/DeleteExportsPropertyDeopt [8.7
... (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/bundler_cjs2esm.test.ts" test/bundler/transpiler/assign-to-import.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.0 (470e769df)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [787.54ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [410.41ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [364.59ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [373.29ms]
(pass) bundler > cjs2esm/ExportsFunction [357.46ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [356.66ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [373.07ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [580.40ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [576.65ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [410.09ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [430.34ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrapping [398.20ms]
(pass) bundler > cjs2esm/ReactSpecificUnwrappi
... (truncated)

release with fix: 22 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 668ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/6] 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 v
... (truncated)
diff hotspot
src/js_parser/fold.rs                            | 28 ++++++--
 src/js_parser/visit/visit_expr.rs                |  9 ++-
 src/js_printer/lib.rs                            |  3 +
 src/jsc/RuntimeTranspilerCache.rs                |  3 +-
 test/bundler/bundler_cjs2esm.test.ts             | 30 +++++++++
 test/bundler/transpiler/assign-to-import.test.ts | 11 ++++
 test/bundler/transpiler/transpiler.test.js       | 83 ++++++++++++++++++++++++
 7 files changed, 159 insertions(+), 8 deletions(-)

gate history · 5 passed · 0 rejected · iteration 5

evidence per changed file
file                                              reads  edits  tests
src/js_parser/fold.rs                                 8      6      0
src/js_parser/visit/visit_expr.rs                     6      5      0
src/js_printer/lib.rs                                 4      1      0
src/jsc/RuntimeTranspilerCache.rs                     2      2      0
test/bundler/bundler_cjs2esm.test.ts                  3      2      0
test/bundler/transpiler/assign-to-import.test.ts      1      1      0
test/bundler/transpiler/transpiler.test.js            1     12      0

The UnDelete visit arm never wrote p.delete_target, so every
is_delete_target check derived from it (in e_identifier, e_dot,
e_index, e_import_meta, handle_identifier, and
maybe_rewrite_property_access) was permanently false. The port carried
the field and all the guards over from esbuild but dropped the one
assignment that makes them meaningful.

Observable on main with minify_syntax (default for `bun run`):

  delete [obj.p][0]         -> folded to `delete obj.p` (runtime wrong)
  delete {f: x}.f           -> folded to `delete x`
  delete E.A (TS enum)      -> folded to `delete 1`
  delete ns.prop (bundle)   -> no "Cannot assign to import" error
  delete exports.foo        -> CJS->ESM unwrap not deoptimized
  delete DEFINED (--define) -> substituted to `delete <value>`

The bun-specific "foo"[n] and [x][n] folds in e_index also ran with no
delete/assign-target guard at all, so the first case above stayed
broken even with delete_target wired. Those now bail when the index
expression is a delete target or an assignment target (`[y][0] = 5`
previously folded to `y = 5`).

RuntimeTranspilerCache version bumped since the runtime transpiler
enables minify_syntax.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Property-access minification now preserves delete, assignment, increment, and call-target semantics. Import.meta handling and the transpiler cache version are updated. Regression tests cover transpilation, CommonJS conversion, and namespace import deletion.

Delete semantics

Layer / File(s) Summary
Parser guards and cache version
src/js_parser/visit/visit_expr.rs, src/js_parser/fold.rs, src/jsc/RuntimeTranspilerCache.rs
Delete operands are tracked during visitation. Property-access folding skips delete and assignment targets, including special import.meta and HMR cases. The cache format version changes to 26.
Transpiler regression coverage
test/bundler/transpiler/transpiler.test.js
Tests cover delete, assignment, increment, call-receiver, enum, define, and runtime behavior.
Bundler delete integration
test/bundler/bundler_cjs2esm.test.ts, test/bundler/transpiler/assign-to-import.test.ts
Tests cover CommonJS export deletion and rejection of namespace import property deletion.

Possibly related PRs

  • oven-sh/bun#35958: Both changes preserve delete operand semantics during expression substitution.
  • oven-sh/bun#36730: Both changes guard property-access inlining to preserve reference semantics.
  • oven-sh/bun#36740: Both changes address delete behavior in folded CommonJS and import expressions.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary parser change: setting p.delete_target before visiting delete operands.
Description check ✅ Passed The description explains the problem, implementation, scope, limitations, and verification results in sufficient detail.
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.

@github-actions github-actions Bot added the claude label Aug 1, 2026
Comment thread src/js_parser/visit/visit_expr.rs
[o.m][0]() has this = the temporary array; folding to o.m() makes
this = o. Add !is_call_target to the same guard (delete/assign/call
are the three positions where a property reference behaves differently
from its value) and cover it in the existing tests.
Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 163-171: Add the missing ts.parsedMin assertion in the “does not
inline an enum member under delete” test for delete E["A"], expecting the
minified output to remain delete E["A"]; and preserve the existing parsed and
read-access assertions.
🪄 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: f16d8a30-7db6-4d52-aed2-f1c096ee7631

📥 Commits

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

📒 Files selected for processing (5)
  • src/js_parser/visit/visit_expr.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/bundler_cjs2esm.test.ts
  • test/bundler/transpiler/assign-to-import.test.ts
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/transpiler.test.js
The EImportMeta arm in maybe_rewrite_property_access produced values
(undefined, require.main == module, string literals) where a property
reference is required. `delete import.meta.hot` became `delete undefined`
(strict-mode SyntaxError) and `delete import.meta.main` under
--format=cjs became `(delete require.main) == module`.

Gate the inline branches on !is_delete_target && assign_target == None
and fall through to the plain E::Dot return, matching the neighbouring
EObject/enum arms. Also add the parsedMin assertion for
`delete E["A"]` suggested in review.

@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/visit/visit_expr.rs:1230-1233 — The Op::UnDelete arm now sets p.delete_target (mirroring esbuild's p.deleteTarget = e.Value.Data) but omits the rest of that same esbuild arm: the canBeDeletedBefore/canBeDeletedAfter check that wraps the operand in (0, x) when visiting turns a non-Reference into a Reference. Because e_if folds *e = e_.yes at line 1514 with no is_delete_target guard and no minify_syntax gate, delete (true ? a.b : 0) becomes delete a.b under plain bun run — Node prints 1, Bun prints undefined. Per REVIEW.md ("for ported code, the reference implementation is the spec — diff control flow against it" / "fix the whole class"), either port the before/after wrap here or add is_delete_target handling in e_if; the cache version was already bumped for this class, so deferring means another bump.

    Extended reasoning...

    What the bug is

    The PR ports one line of esbuild's case js_ast.UnOpDelete:p.deleteTarget = e.Value.Data — into the Op::UnDelete arm at visit_expr.rs:1231. But esbuild's arm does more: it captures canBeDeletedBefore := canBeDeleted(e.Value) before visiting, canBeDeletedAfter after, and if visiting turned a non-Reference operand into a Reference (canBeDeletedAfter && !canBeDeletedBefore), wraps it as (0, x) so delete still sees a value. Bun's arm has none of that — it is exactly two lines — and no can_be_deleted helper exists anywhere in src/ (grep confirms).

    The newly-activated is_delete_target guards in e_dot/e_index/e_identifier/etc. cover folds of the direct operand, but they cannot help when a fold replaces the whole operand node with a different kind. e_if (lines 1470–1544) does exactly that: it computes only is_call_target (line 1472), never reads p.delete_target, and for a compile-time-constant test does *e = e_.yes at line 1514 (or *e = e_.no at 1541) with only an is_call_target && has_value_for_this_in_call() guard at line 1507. There is no minify_syntax gate on this fold.

    Step-by-step proof

    var a = { b: 1 };
    delete (true ? a.b : 0);
    console.log(a.b);
    1. Op::UnDelete sets p.delete_target = <EIf node> and visits the operand.
    2. e_if computes is_call_target = false (the EIf is not p.call_target). It never checks p.delete_target.
    3. SideEffects::to_boolean on the literal true returns ok=true, value=true, NoSideEffects, so control reaches line 1507.
    4. is_call_target is false, so line 1514 runs: *e = e_.yes — the operand becomes the EDot node a.b.
    5. Back in Op::UnDelete, there is no post-visit check, so the emitted code is delete a.b.

    Spec: Per ECMA-262 §13.14.3, ConditionalExpression applies GetValue to the selected branch, so (true ? a.b : 0) evaluates to the value 1, not a Reference; delete <value> returns true and deletes nothing. Node prints 1. After the fold, delete a.b actually deletes the property; Bun prints undefined.

    Why nothing else prevents it

    • e_if never reads p.delete_target, so wiring it up in this PR did nothing for this path.
    • The fold is not gated on minify_syntax, so it fires under plain bun run, not just bun build --minify.
    • The (0, x) wrap machinery already exists for the call-target case at line 1507 (has_value_for_this_in_call), demonstrating the codebase models this exact hazard for calls but omits it for delete.
    • esbuild's general fix — the before/after check in the UnOpDelete arm itself — is what covers any fold that changes the operand's Reference-ness, without instrumenting every fold site. That is the half of the arm this PR did not port.

    Why this belongs in this PR

    This is pre-existing (the e_if fold predates the PR), but:

    • The PR description says it is "mirroring esbuild's p.deleteTarget = e.Value.Data", and the wrap lives in that same esbuild arm. REVIEW.md: "For ported code, the reference implementation (esbuild, Node) is the spec — diff control flow against it before 'fixing' apparent bugs."
    • It is the same bug class the PR is fixing ("replacing a property reference with a value under delete", inverted: replacing a value-producing form with a reference). REVIEW.md: "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep)."
    • The PR already accepted this exact reasoning once: the earlier review comment about !is_call_target at the [x][0] fold was applied in commit ab3843d on the same "fix the whole class" basis.
    • RuntimeTranspilerCache version was bumped to 25 for this class of change. If the wrap is added in a later PR, the version must be bumped again, and any cache entry written between the two PRs persists the miscompile.

    Fix

    Either port esbuild's canBeDeletedBefore/canBeDeletedAfter check into the Op::UnDelete arm (the general fix — a small can_be_deleted helper matching EDot/EIndex/EIdentifier, and if after && !before wrap the visited operand as (0, operand)), or add an is_delete_target computation in e_if and gate the *e = e_.yes / *e = e_.no folds the same way is_call_target is gated. The former is preferable because it also covers any other fold that changes operand kind (e.g. comma-operator simplification). Add a test alongside the new delete cases:

    ts.expectPrintedMin_("x = delete (true ? a.b : c)", "x = delete (true ? a.b : c)");
    // or, if the wrap is ported: "x = delete (0, a.b)"

    and a runtime case in the "preserves delete/assign/call-receiver semantics at runtime" test.

  • 🟡 src/js_parser/visit/visit_expr.rs:1060-1070 — The sibling bun-specific fold "str".length → <number> in maybe_rewrite_property_access (fold.rs:445-453, EString arm) has the same reference→value hazard but no is_delete_target/assign_target guard — delete "abc".length still folds to delete 3 (strict-mode TypeError → true, sloppy falsetrue). The adjacent EObject arm (fold.rs:470-473) already checks all three, and this PR now delivers is_delete_target=true to the EString arm via .with_is_delete_target(is_delete_target) at line 1442; it just never reads it. One-line guard + ts.expectPrintedMin_('x = delete "foo".length', 'x = delete "foo".length') alongside the new delete "foo"[2] case.

    Extended reasoning...

    What the bug is

    The PR guards the bun-specific "foo"[n] → "o" and [x][0] → x folds against delete/assign/call targets (visit_expr.rs:1064-1070) on the grounds that "a[n] is a property reference; replacing it with a value changes the result of delete". But the sibling bun-specific fold "str".length → <number> in maybe_rewrite_property_access (src/js_parser/fold.rs:445-453, EString arm) is the same shape — a property reference on a string literal replaced by a value — and has no such guard:

    js_ast::ExprData::EString(str_) => {
        if p.options.features.minify_syntax {
            // minify "long-string".length to 11
            if name == b"length" {
                if let Some(len) = e_string_javascript_length(&str_) {
                    return Some(p.new_expr(E::Number::new(len as f64), loc));
                }
            }
        }
    }

    identifier_opts.is_delete_target() and .assign_target() are never consulted, even though the immediately adjacent EObject arm (fold.rs:470-473) checks all three (!is_delete_target(), assign_target() == None, !is_call_target()).

    Concrete walk-through

    delete "abc".length with minify_syntax on:

    1. Op::UnDelete (visit_expr.rs:1231, added by this PR) sets p.delete_target = EDot("abc".length).
    2. e_dot computes is_delete_target = true (line 1347) and calls maybe_rewrite_property_access(..., IdentifierOpts::default()...with_is_delete_target(is_delete_target)) at lines 1434-1442.
    3. fold.rs:445-453 matches EString, checks only p.options.features.minify_syntax && name == b"length", and returns Some(E::Number(3.0))identifier_opts is never read.
    4. Output: delete 3.

    Observable difference

    • Sloppy mode: delete "abc".lengthfalse (String exotic wrapper .length is a non-configurable own property; [[Delete]] returns false). After fold: delete 3true.
    • Strict mode (all ES modules, bun run default): delete "abc".lengthTypeError: Cannot delete property 'length' of [object String]. After fold: delete 3true, silently swallowing the throw.

    The assign case is worse in principle: "abc".length = 5 folds the LHS to 3, emitting the syntactically-invalid assignment 3 = 5.

    Why nothing else prevents it

    e_dot reaches maybe_rewrite_property_access before any other is_delete_target-aware path can intervene, and the EString arm returns Some(...) unconditionally on minify_syntax && name == b"length". The flag is delivered — thanks to this PR — but the arm never reads it. Before this PR the flag was always false anyway (the assignment at line 1231 didn't exist), so the fold fired for the same reason; this PR wires the flag correctly to every consumer except this one arm.

    Why this is in scope for this PR

    This PR's stated purpose is to wire p.delete_target and add the missing guard to the bun-specific string/array-index folds. The .length fold is another bun-specific string-literal fold with the identical reference→value hazard, and the PR's own new test at transpiler.test.js:150 asserts x = delete "foo"[2] stays as-is — x = delete "foo".length is the exact same shape. The PR's RuntimeTranspilerCache version-25 comment even lists delete "s"[n] as no longer folding to a value; .length is the sibling case. Per REVIEW.md ("Fix the whole class in the same PR … Grep for every sibling site sharing the pattern"), the EString arm is the odd one out next to the already-guarded EObject arm.

    Fix

    Gate fold.rs:446-452 on !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None (matching the EObject arm; is_call_target is optional here since calling a number throws either way). Add ts.expectPrintedMin_('x = delete "foo".length', 'x = delete "foo".length') alongside the new delete "foo"[2] case in transpiler.test.js.

    Marked nit: real correctness bug and a legitimate same-class omission, but the trigger (delete "literal".length) is highly contrived and the observable output is unchanged by this PR (the fold fired before too, since is_delete_target was permanently false). One-line guard + one test line.

  • 🟡 src/js_parser/visit/visit_expr.rs:1066-1070 — The guard now covers delete/assign/call, but tagged-template tags are the fourth position with call-receiver semantics: [o.m][0]`x` binds this to the temporary array, but folding to o.m`x` binds this to o. e_template (line 695-696) visits the tag via plain p.visit_expr without setting p.call_target, so is_call_target is false there and the fold fires. A full fix needs the p.template_tag field the port has TODO'd at lines 1044/1443 — probably a follow-up; for now the comment at 1060-1065 could note the gap so it doesn't read as exhaustive.

    Extended reasoning...

    What the bug is

    The guard at lines 1066-1070 now covers three of the four positions where a property Reference's base is observably distinct from the value it produces: delete target, assignment target, and call target. Tagged-template tags are the fourth. Per ECMA-262, a tagged template evaluates its tag as a Reference and passes it through EvaluateCall, which uses the Reference base as thisValue — exactly the same receiver semantics as a plain call. So [o.m][0]`tpl` invokes o.m with this bound to the temporary array [o.m], but folding it to o.m`tpl` binds this to o.

    Why the current guard doesn't catch it

    e_template (line 695-696) visits the tag via p.visit_expr(e_.tag.as_mut().unwrap()) — a plain visit that does not write p.call_target (only e_call does that) and there is no p.template_tag field to write. So when e_index runs on the tag expression, is_call_target at line 887 is false, is_delete_target is false, and in_.assign_target is None — the guard passes and the fold at ~1097-1104 fires. can_be_inlined_from_property_access (src/ast/expr.rs:53-61) rejects only ESpread/EMissing, so EDot passes and [o.m][0] becomes o.m.

    Step-by-step proof

    With minify_syntax on:

    var o = { m() { return this === o } };
    console.log([o.m][0]`x`);
    1. e_template visits its tag [o.m][0] via plain p.visit_expr(); p.call_target is not set to this node.
    2. e_index computes is_call_target = ptr::eq(p.call_target, e.data) at line 887 → false. is_delete_targetfalse. in_.assign_targetNone.
    3. The guard at 1066-1070 passes. target.data.as_e_array() matches a single-element array; index is 0.0; the element o.m is EDot, which can_be_inlined_from_property_access accepts.
    4. *e = inlined; return; — the tag is now o.m, and the emitted code is o.m`x`.

    Before the fold: the Reference base of [o.m][0] is the array [o.m], so this inside m is that array; this === o is false. Node prints false.
    After the fold: o.m`x`this === o is true. Bun prints true.

    Why this is nit, not blocking

    Unlike the previous round's !is_call_target addition — a one-token change using a local already computed at line 887 — closing this gap requires porting the p.template_tag parser-state field that doesn't exist yet. It's TODO'd at lines 1044 and 1443 (// .is_template_tag = is_template_tag, / // .is_template_tag = p.template_tag != null,), and grep confirms no template_tag field on the parser struct. Wiring it means: adding the field alongside call_target/delete_target in p.rs, setting it in e_template before visiting the tag, computing is_template_tag in e_index/e_dot/e_identifier, and un-TODO'ing the two existing sites. That's a separate porting task with its own test surface, and the fold was already unguarded for template tags before this PR — nothing newly regresses.

    REVIEW.md's "fix the whole class in the same PR" was the basis for the previous round adding !is_call_target, and this is unambiguously the same class. But that rule is calibrated for same-effort sibling sites; here the fourth sibling requires infrastructure the port lacks. The PR strictly improves the guard (0/4 → 3/4), so blocking on the missing port feature isn't proportionate.

    How to fix

    Either wire p.template_tag in this PR (mirroring esbuild's p.templateTag = e.Tag.Data before visiting the tag, then && !is_template_tag on this guard and un-TODO'ing 1044/1443), or — if that's out of scope — amend the comment at 1060-1065 to note tagged templates as a known gap so it doesn't claim to enumerate all Reference-observable positions, and file a follow-up.

`delete "abc".length` folded to `delete 3` (true) instead of `false`.
The EString arm in maybe_rewrite_property_access now matches its
neighbouring EObject arm's guard.

Also lock in the existing printer-side `(0, x)` wrap for
`delete (true ? a.b : 0)` with a test; bun handles that case via
WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS at print time
rather than a visit-time before/after check.
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

On the three additional findings:

delete (true ? a.b : 0): bun already handles this at print time. parse_prefix.rs sets UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS only when the parsed operand is an EIdentifier/EDot/EIndex, and the printer (src/js_printer/lib.rs:4004) wraps the operand in (0, ...) when the flag is unset but the visited operand now is one. So the e_if fold emits delete (0, a.b), matching esbuild. I've added ts.expectPrinted_("x = delete (true ? a.b : 0)", "x = delete (0, a.b)") in d7f60ac to lock this in:

$ bun -e 'var a = { b: 1 }; delete (true ? a.b : 0); console.log(a.b);'
1

delete "foo".length: correct, added the guard in d7f60ac. esbuild has the same bug (x = delete 3), but since identifier_opts.is_delete_target() is already delivered to the EString arm, gating it is the one-liner suggested.

**[o.m][0]\x`**: correct that it's the fourth reference-observable position, but bun has no p.template_tagfield yet (the two TODO sites at visit_expr.rs:1044/1443 confirm). #36599 adds that infrastructure; once it lands, the same&& !is_template_tag` can be appended here. Leaving this one out to avoid pulling a parser-state port into this fix.

Comment thread src/js_parser/visit/visit_expr.rs
Comment thread src/js_parser/visit/visit_expr.rs
Comment thread src/js_parser/visit/visit_expr.rs
Comment thread src/js_parser/fold.rs
…n printer wrap

The HotEnabled/HotDisabled arm in maybe_rewrite_property_access returned
EUndefined for `import.meta.hot.accept` etc. with HMR off, producing
`delete undefined` / `undefined = fn`. Bail to the outer E::Dot so the
output stays a property reference (`undefined.accept`, which throws at
runtime exactly as the unfolded input would).

Also add lock-in transpiler tests for `delete (0, a.b)` / `?? || && ?:`.
bun handles those at print time via
WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS (set in
parse_prefix, checked in the printer) rather than esbuild's visit-time
canBeDeleted before/after wrap; the output and runtime behaviour match
esbuild.
Comment thread src/js_parser/fold.rs
…abled

Under HMR-enabled (Bake dev server), every rewrite in this arm produces
a valid hmr.<name> property reference; bailing left
`ESpecial::HotEnabled` in the tree, which prints as `hmr.indirectHot`
(a throwing Proxy), so `import.meta.hot.data = obj` regressed. The
delete-undefined/assign-undefined hazard only exists on the !enabled
branches.
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/fold.rs
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.

No new findings this pass; all prior threads are resolved. Deferring to a human because this changes the transpiler visit pass (affects every file bun run / bun build touches) and bumps the runtime transpiler cache version.

What was reviewed:

  • p.delete_target assignment vs. esbuild's p.deleteTarget = e.Value.Data; confirmed the print-time WAS_ORIGINALLY_DELETE_... re-wrap covers the value→reference direction, so the omitted canBeDeletedAfter port is intentional.
  • The !enabled gate on the HotEnabled|HotDisabled bail — verified it no longer routes import.meta.hot.data = x through hmr.indirectHot under the dev server.
  • Remaining sibling gaps (tagged-template tag receiver, dot-define under delete, EImportIdentifier in the printer re-wrap) are all pre-existing / byte-identical before and after, and documented in the PR body.
Extended reasoning...

Overview

The PR restores a one-line assignment dropped from the esbuild port — p.delete_target = e_.value.data in the Op::UnDelete visit arm — which makes every existing is_delete_target guard in e_identifier/e_dot/e_index/handle_identifier/maybe_rewrite_property_access reachable for the first time. It then adds the same !is_delete_target && assign_target == None (and !is_call_target where receiver identity matters) gate to four bun-specific folds that had none: the [x][n]/"s"[n] index fold, the "str".length fold, the import.meta.{main,hot,dir,...} inline, and the import.meta.hot.<method> inline. RuntimeTranspilerCache version is bumped 24→25. Tests cover transpiler output, bun build diagnostics, cjs2esm de-opt, and end-to-end runtime semantics.

Security risks

None. Pure transpiler-output correctness; no auth/crypto/permissions/network surface.

Level of scrutiny

High. visit_expr.rs and fold.rs run on every file the runtime transpiler and bundler process, and minify_syntax is on by default for bun run. The cache version bump invalidates every existing .pile on users' machines. This is exactly the kind of change where a subtle guard error miscompiles real code silently.

Other factors

This PR has already been through three review passes from me. I raised one real regression (the HotEnabled|HotDisabled guard breaking import.meta.hot.data = x under the Bake dev server — routed through the throwing hmr.indirectHot Proxy), which was fixed in d8337c4 by gating on !enabled. I raised two same-class sibling gaps ("str".length and import.meta.hot.<method>), both fixed. The three remaining items — tagged-template tags (needs p.template_tag from #36599), string-valued dot-defines under delete (matches esbuild), and EImportIdentifier/ECommonjsExportIdentifier missing from the printer's re-wrap helper — are all pre-existing, byte-identical before and after, and now documented in the PR body's "Not changed" section.

Test coverage is thorough (transpiler output assertions, runtime spawn test, bundler diagnostics, cjs2esm de-opt) and verified to fail on main. The change is correct as far as I can tell, but given the blast radius and that it took multiple rounds to converge, a maintainer should sign off.

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

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: conflicts resolved in c320ace (merged main; #36730 had landed with overlapping guards on the [x][0] fold and the printer helper, kept its (0, x) call-receiver wrap and added !is_delete_target to it; cache version bumped to 26).

#88723: 171 passed so far. All bundler/transpiler/parser tests (the files this PR touches) are green. The one [new] failure is test-worker-message-port-transfer-terminate.js (JSC ExceptionScope::assertNoException SIGABRT on asan), which is Worker/MessagePort C++ and unrelated to this parser diff; reported for main-break triage. Everything else is [flaky]-tagged (bake/deinit timeout on windows-aarch64, pretty-format-overflow, zlib-estimated-size-gc, napi GC timing).

The gate (robobun/evidence) passed on the earlier sha on both ASAN and release. Ready for merge pending the build finishing.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:52 AM PT - Aug 4th, 2026

@robobun, your commit c320ace has 1 failures in Build #88723 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36734

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

bun-36734 --bun

`--drop=Bun` on `delete Bun.inspect()` replaces the call with
EUndefined; without minify that prints as `delete undefined`, which
parses back as delete-of-a-bare-identifier (strict-mode SyntaxError).
The test passed on main only because the runtime transpiler then
substituted `undefined` to `void 0` while is_delete_target was
permanently false.

is_identifier_or_numeric_constant_or_property_access already covers
ENumber(inf/nan) for the same reason (they print as `Infinity`/`NaN`);
add EUndefined so the bundled output is `delete (0, undefined)` and
stays valid under strict mode.

Fixes bundler_drop.test.ts drop/DeleteAssignTarget.
Comment thread src/js_printer/lib.rs Outdated
Comment thread src/js_printer/lib.rs Outdated
Jarred-Sumner pushed a commit that referenced this pull request Aug 4, 2026
…6740)

## Problem

```js
// m.js: export let x = 1;
import * as ns from "./m.js";
console.log(delete (null ?? ns.x), ns.x);
```

```console
$ node entry.mjs
true 1
$ bun build ./entry.mjs
// m.js
var x = 1;
// entry.mjs
console.log(delete x, x);
$ node <(bun build ./entry.mjs)
SyntaxError: Delete of an unqualified identifier in strict mode.
```

`delete (null ?? ns.x)` evaluates its operand to a value, so per spec it
returns `true` with no effect. The bundler correctly emits `delete (0,
ns.x)` under `--no-bundle`, but when bundling the `??` folds away and
`ns.x` is rewritten to an `EImportIdentifier` pointing at the hoisted
binding. The printer's `(0, ...)` re-wrap at
`src/js_printer/lib.rs:4004` is driven by
`is_identifier_or_numeric_constant_or_property_access`, which only
recognised `EIdentifier | EDot | EIndex` and so let the
`EImportIdentifier` through as a bare `delete x`.

The same gap applies to every visit-pass rewrite that can land as a
`delete` operand after a fold and prints as an identifier or property
access:

| node | example | previously printed |
|---|---|---|
| `EImportIdentifier` | `delete (null ?? ns.x)` (namespace import) |
`delete x` |
| `ECommonjsExportIdentifier` | `delete (null ?? exports.a)` under
cjs2esm | `delete $a` |
| `ESpecial::ModuleExports` | `delete (null ?? module.exports)` under
cjs2esm | `delete exports_entry` |
| `ERequireCallTarget` | `delete (null ?? require)` | `delete __require`
|
| `ERequireMain` / `ERequireResolveCallTarget` | `delete (null ??
require.main)` | `delete __require.main` |
| `EInlinedEnum` wrapping `NaN`/`Infinity` | `const enum E { N = 0/0 };
delete (null ?? E.N)` | `delete NaN` |
| `EUndefined` | `delete (null ?? undefined)` (define substitution) |
`delete undefined` |
| `EImportMetaMain` | `delete (null ?? import.meta.main)` | `delete
import.meta.main` / `delete __require.main == __require.module` |
| `EImportMeta` (bake dev / CJS ref) | `delete (null ?? import.meta)` |
`delete hmr.importMeta` |

Each bare-identifier form is a strict-mode SyntaxError in the emitted
ESM bundle; the property-access forms delete a property the source never
touched.

`WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS` is set at parse
time from the syntactic operand, so it is correctly unset for `delete
(null ?? ns.x)`; the wrap just needs to recognise the post-visit node
kinds.

## Fix

Extend `is_identifier_or_numeric_constant_or_property_access` to match
`EImportIdentifier | ECommonjsExportIdentifier | ESpecial |
ERequireCallTarget | ERequireResolveCallTarget | ERequireMain |
EImportMeta | EImportMetaMain | EUndefined`, and recurse through
`EInlinedEnum` so the existing `NaN`/`Infinity` check reaches the
wrapped value. The helper is only consulted when the parse-time flag is
not set, so `delete ns.x` / `delete require` (flag set) continue to
print without the wrap and this can only add wraps where the source
never had Reference semantics.

esbuild's `isIdentifierOrNumericConstantOrPropertyAccess` has the same
`EImportIdentifier` gap and `esbuild --bundle` miscompiles the first
repro the same way; the other node kinds are either Bun-specific or
print differently in esbuild.

This is orthogonal to #36734 (which makes `p.delete_target` live in the
visitor): `ns.x` inside `(null ?? ns.x)` is not the delete target there
either, so that PR does not cover this path.

## Verification

```console
$ bun bd test test/bundler/bundler_edgecase.test.ts -t DeleteFolded   # 8 new tests; fail on main
$ bun bd test test/bundler/bundler_cjs2esm.test.ts -t DeleteFolded    # 3 new tests; fail on main
```

Full `bundler_edgecase.test.ts`, `bundler_cjs2esm.test.ts`,
`bundler_minify.test.ts`, `transpiler.test.js`,
`esbuild/default.test.ts`, `esbuild/ts.test.ts`, `esbuild/dce.test.ts`
unchanged.

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

---

**no test proof** · iteration 8 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/bundler/bundler_edgecase.test.ts

<!-- robobun:evidence:end -->
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

…er-delete-target

# Conflicts:
#	src/js_parser/visit/visit_expr.rs
#	src/js_printer/lib.rs
#	src/jsc/RuntimeTranspilerCache.rs
#	test/bundler/bundler_cjs2esm.test.ts
#	test/bundler/transpiler/transpiler.test.js
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Conflicts resolved in c320ace. Main had merged #36730 (the [x][0] optional-chain PR) which already added assign_target == None to the same guard and handles is_call_target via a (0, x) wrap inside the fold; kept that structure and added !is_delete_target to it. Main also extended the printer's is_identifier_or_numeric_constant_or_property_access helper with EImportIdentifier/ECommonjsExportIdentifier/ESpecial/EUndefined/etc. (superset of my EUndefined addition), took that verbatim. RuntimeTranspilerCache bumped to 26 since main already claimed 25. All test files merged with both test sets kept; 13 property-access-inlining tests, 28 cjs2esm, 15 assign-to-import, 14 drop tests green locally.

Comment thread src/js_parser/visit/visit_expr.rs

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

1232-1233: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore P::delete_target after the operand visit.

Op::UnDelete writes the marker at Line 1232 and leaves it active. P::e_import_meta checks only Data::EImportMeta, not the operand identity. After delete import.meta, a later unrelated import.meta can receive the delete-target flag and skip its valid define rewrite.

Save the previous marker, visit the operand, and restore the previous marker. This also preserves an outer marker for nested deletes.

Proposed fix
 Op::UnDelete => {
+    let previous_delete_target = p.delete_target;
     p.delete_target = e_.value.data;
     p.visit_expr_in_out(&mut e_.value, ExprIn::default());
+    p.delete_target = previous_delete_target;
 }

As per coding guidelines, “Store state on the object whose lifetime matches it, reset reusable per-operation state...”

🤖 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 1232 - 1233, Update the
Op::UnDelete handling around p.delete_target and p.visit_expr_in_out to save the
existing delete_target, set the operand marker, visit the operand, then restore
the saved marker afterward. Preserve any outer marker during nested deletes and
ensure later unrelated import.meta expressions do not inherit the temporary
state.

Source: Coding guidelines

🤖 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 1232-1233: Update the Op::UnDelete handling around p.delete_target
and p.visit_expr_in_out to save the existing delete_target, set the operand
marker, visit the operand, then restore the saved marker afterward. Preserve any
outer marker during nested deletes and ensure later unrelated import.meta
expressions do not inherit the temporary state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53f58a98-bb5d-45be-99bf-3e316c6e13ba

📥 Commits

Reviewing files that changed from the base of the PR and between ab3843d and c320ace.

📒 Files selected for processing (3)
  • src/js_parser/fold.rs
  • src/js_parser/visit/visit_expr.rs
  • src/jsc/RuntimeTranspilerCache.rs

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

On the save/restore suggestion for p.delete_target: not adding it. esbuild doesn't save/restore p.deleteTarget either, and bun's p.call_target has always followed the same pattern. The only consumer that tag-matches (not pointer-matches) is e_import_meta, and the flag it derives only affects handle_identifier's "Cannot assign to import" error for Import-kind symbols, which an import.meta define never produces. Verified empirically:

$ bun -e 'delete import.meta; x = import.meta.hot;'  # second one still inlines correctly

The self-review probed this exact concern ("stale-delete-target-import-meta-tag-match" and "delete-target-not-reset-after-visit") and dismissed both as not-applicable.

springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…en-sh#36740)

## Problem

```js
// m.js: export let x = 1;
import * as ns from "./m.js";
console.log(delete (null ?? ns.x), ns.x);
```

```console
$ node entry.mjs
true 1
$ bun build ./entry.mjs
// m.js
var x = 1;
// entry.mjs
console.log(delete x, x);
$ node <(bun build ./entry.mjs)
SyntaxError: Delete of an unqualified identifier in strict mode.
```

`delete (null ?? ns.x)` evaluates its operand to a value, so per spec it
returns `true` with no effect. The bundler correctly emits `delete (0,
ns.x)` under `--no-bundle`, but when bundling the `??` folds away and
`ns.x` is rewritten to an `EImportIdentifier` pointing at the hoisted
binding. The printer's `(0, ...)` re-wrap at
`src/js_printer/lib.rs:4004` is driven by
`is_identifier_or_numeric_constant_or_property_access`, which only
recognised `EIdentifier | EDot | EIndex` and so let the
`EImportIdentifier` through as a bare `delete x`.

The same gap applies to every visit-pass rewrite that can land as a
`delete` operand after a fold and prints as an identifier or property
access:

| node | example | previously printed |
|---|---|---|
| `EImportIdentifier` | `delete (null ?? ns.x)` (namespace import) |
`delete x` |
| `ECommonjsExportIdentifier` | `delete (null ?? exports.a)` under
cjs2esm | `delete $a` |
| `ESpecial::ModuleExports` | `delete (null ?? module.exports)` under
cjs2esm | `delete exports_entry` |
| `ERequireCallTarget` | `delete (null ?? require)` | `delete __require`
|
| `ERequireMain` / `ERequireResolveCallTarget` | `delete (null ??
require.main)` | `delete __require.main` |
| `EInlinedEnum` wrapping `NaN`/`Infinity` | `const enum E { N = 0/0 };
delete (null ?? E.N)` | `delete NaN` |
| `EUndefined` | `delete (null ?? undefined)` (define substitution) |
`delete undefined` |
| `EImportMetaMain` | `delete (null ?? import.meta.main)` | `delete
import.meta.main` / `delete __require.main == __require.module` |
| `EImportMeta` (bake dev / CJS ref) | `delete (null ?? import.meta)` |
`delete hmr.importMeta` |

Each bare-identifier form is a strict-mode SyntaxError in the emitted
ESM bundle; the property-access forms delete a property the source never
touched.

`WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS` is set at parse
time from the syntactic operand, so it is correctly unset for `delete
(null ?? ns.x)`; the wrap just needs to recognise the post-visit node
kinds.

## Fix

Extend `is_identifier_or_numeric_constant_or_property_access` to match
`EImportIdentifier | ECommonjsExportIdentifier | ESpecial |
ERequireCallTarget | ERequireResolveCallTarget | ERequireMain |
EImportMeta | EImportMetaMain | EUndefined`, and recurse through
`EInlinedEnum` so the existing `NaN`/`Infinity` check reaches the
wrapped value. The helper is only consulted when the parse-time flag is
not set, so `delete ns.x` / `delete require` (flag set) continue to
print without the wrap and this can only add wraps where the source
never had Reference semantics.

esbuild's `isIdentifierOrNumericConstantOrPropertyAccess` has the same
`EImportIdentifier` gap and `esbuild --bundle` miscompiles the first
repro the same way; the other node kinds are either Bun-specific or
print differently in esbuild.

This is orthogonal to oven-sh#36734 (which makes `p.delete_target` live in the
visitor): `ns.x` inside `(null ?? ns.x)` is not the delete target there
either, so that PR does not cover this path.

## Verification

```console
$ bun bd test test/bundler/bundler_edgecase.test.ts -t DeleteFolded   # 8 new tests; fail on main
$ bun bd test test/bundler/bundler_cjs2esm.test.ts -t DeleteFolded    # 3 new tests; fail on main
```

Full `bundler_edgecase.test.ts`, `bundler_cjs2esm.test.ts`,
`bundler_minify.test.ts`, `transpiler.test.js`,
`esbuild/default.test.ts`, `esbuild/ts.test.ts`, `esbuild/dce.test.ts`
unchanged.

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

---

**no test proof** · iteration 8 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/bundler/bundler_edgecase.test.ts

<!-- robobun:evidence:end -->
Comment thread src/js_parser/fold.rs
Comment thread src/js_parser/fold.rs
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