js_printer: re-wrap delete of folded import/cjs-export identifier - #36740
Conversation
The delete re-wrap helper at print time decides whether a delete operand that was not originally a Reference but folded to one needs the (0, ...) wrap to keep value semantics. It only matched EIdentifier/EDot/EIndex, so EImportIdentifier (ns.x from a namespace import, or a named import binding), ECommonjsExportIdentifier (exports.a under cjs2esm) and ESpecial (module.exports, import.meta.hot.*) fell through and printed as bare 'delete <binding>' or 'delete obj.prop', a strict-mode SyntaxError or an observable semantic change from the source. Extend the helper to cover those visit-pass node kinds.
WalkthroughChangesThe expression classifier now recognizes additional identifier-like and property-access targets, including recursively evaluated inlined enum values. Bundler tests cover folded Folded delete expression handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
LGTM — the inline nits (untested ESpecial arm, additional sibling variants, and the pre-existing react_compiler flag gap) are worth considering but don't affect correctness of what's shipped: the helper's sole call site only ever adds the (0, ...) wrap when the parse-time flag is unset, so the new arms cannot change semantics for any input that was previously correct.
Extended reasoning...
Overview
Adds three match arms (EImportIdentifier | ECommonjsExportIdentifier | ESpecial) to is_identifier_or_numeric_constant_or_property_access in src/js_printer/lib.rs, plus 5 new itBundled tests across bundler_edgecase.test.ts and bundler_cjs2esm.test.ts. Fixes delete (null ?? ns.x) bundling to delete x (a strict-mode SyntaxError) after the visit pass folds ?? and rewrites ns.x to a synthetic identifier node.
Security risks
None. Pure output-correctness fix in the JS printer; no untrusted-input parsing, no allocation, no I/O.
Level of scrutiny
Low. The helper has exactly one call site (lib.rs:4011), guarded by !e.flags.contains(WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS), and returning true only ever emits an extra (0, ...) wrap around a delete operand that was not syntactically a Reference in source. Over-wrapping is semantically a no-op (delete (0, X) → evaluate X, return true), so the change is monotone-safe: it can fix miscompiles but cannot introduce new ones for parser-produced ASTs. The PR description's reasoning matches the code.
Other factors
The 5 new tests all use run: { stdout: ... } (not just output regex), so they verify runtime semantics, and 3 of them additionally assert the miscompiled shape is absent. Full suites (bundler_edgecase, bundler_cjs2esm, bundler_minify, transpiler, esbuild/default, esbuild/dce) reported passing. The three inline findings are all non-blocking: two are nits (uncovered ESpecial arm, additional sibling node kinds for even-more-pathological inputs like delete (null ?? require)), and one is a pre-existing react_compiler/codegen.rs bug that this PR neither introduces nor widens.
…Special Addresses review: - ERequireCallTarget / ERequireMain / ERequireResolveCallTarget print as __require / __require.main / __require.resolve; a folded delete operand now re-wraps. - EInlinedEnum recurses into its inner value so NaN/Infinity from a same-file const enum reach the existing ENumber check. - EImportMeta can print as a property access or identifier depending on output mode. - Add a dedicated ESpecial::ModuleExports output-shape test so removing the ESpecial arm breaks a test. - Tighten the helper comment.
There was a problem hiding this comment.
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_printer/lib.rs`:
- Around line 1219-1227: Add a printer or transpiler regression test covering
`delete (null ?? import.meta)`, ensuring the emitted output contains the `(0,
import.meta)` wrapper. Keep the test focused on preserving the `EImportMeta`
printing path, which bundled tests do not exercise.
🪄 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: e7e8b065-fea5-4678-8419-6313dfa15409
📒 Files selected for processing (3)
src/js_printer/lib.rstest/bundler/bundler_cjs2esm.test.tstest/bundler/bundler_edgecase.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js_printer/lib.rs:1225-1232— Two more same-class siblings still fall through to_ => falseafter 14d0f4b:EImportMetaMainandEUndefined.delete (null ?? import.meta.main)bundles todelete import.meta.main(deletes the property; undertarget: nodeit emitsdelete __require.main == __require.module, which deletes__require.main), anddelete (null ?? undefined)bundles todelete undefined(strict-mode SyntaxError) because the visit pass substitutes unboundundefined→EUndefinedandprint_undefinedemits the bare identifier without minify. Adding| ExprData::EImportMetaMain(_) | ExprData::EUndefined(_)to the arm covers both.Extended reasoning...
What's missing
The follow-up commit 14d0f4b extended the arm at src/js_printer/lib.rs:1225-1231 to cover
ERequireCallTarget | ERequireResolveCallTarget | ERequireMain | EImportMetaand addedEInlinedEnumrecursion, but two more visit-pass-produced discriminants still hit_ => falseat line 1234:EImportMetaMain— a distinct discriminant fromEImportMeta(src/ast/expr.rs:1491).import.meta.mainis rewritten bymaybe_rewrite_property_access(fold.rs:494-496 →p.value_for_import_meta_main()) on everyEDot{target: EImportMeta, name: "main"}, not gated byis_delete_target. The existing testedgecase/ImportMetaMainproves this node reaches the printer for entry files (it captures the literal stringimport.meta.main).EUndefined— the visit pass substitutes an unboundundefinedidentifier viadefines.for_identifier(b"undefined")→PureGlobalIdentifierValue::StrictUndefined→ExprData::EUndefined(defines_table.rs:232-243, visit_expr.rs:261-282). The pre-existingENumberarm at line 1233 already handles the sibling define-substitutions forNaN/Infinityfrom the same table;EUndefinedis the one member of that trio the helper doesn't catch.
Step-by-step:
EUndefined- Source:
console.log(delete (null ?? undefined))in an ESM entry. - Parse: operand is
EBinary(??), soWAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESSis not set (parse_prefix.rs only sets it forEIdentifier|EDot|EIndex). - Visit RHS:
undefinedis unbound andis_delete_targetis false (the delete target is theEBinary, not this identifier — visit_expr.rs:177 checksmatches!(p.delete_target.tag(), Tag::EIdentifier)), so define substitution runs and*e = EUndefined. null ?? EUndefinedfolds toEUndefinedat visit_binary.rs:363-383 (unconditional, not gated onminify_syntax— the PR's own non-minifiedDeleteFoldedNamespacePropertyReftest relies on this fold).- Printer at lib.rs:4018: flag unset,
is_identifier_or_numeric_constant_or_property_access(EUndefined)→_ => false→ no(0, ...)wrap. print_undefined(lib.rs:1818-1832) at levelPrefix.sub(1)withoutminify_syntaxprints bareundefined.
Output:
delete undefinedin module code (always strict) — an early SyntaxError (undefinedis an IdentifierReference, not a keyword), the identical failure mode this PR fixes forEImportIdentifier. Source semantics istruewith no effect. Withminify_syntax,print_undefinedemitsvoid 0anddelete void 0is fine, so this only miscompiles in the non-minified path — consistent with the PR's own non-minified test coverage.Step-by-step:
EImportMetaMainSame trace with
delete (null ?? import.meta.main): parse-time operand isEBinary, visit rewritesimport.meta.main→EImportMetaMain,??folds, helper returnsfalse, no wrap.Printer output (lib.rs:2869-2919):
- ESM / non-node (default): prints
import.meta.main— a property access.delete import.meta.mainattempts to delete the property fromimport.meta(behavior change; source evaluates totruewith no effect). target: node: prints__require.main == __require.modulewith no level-based paren wrapping in that arm.deletebinds tighter than==, sodelete __require.main == __require.moduleparses as(delete __require.main) == __require.module— deleting__require.mainand comparing the boolean. The existing testedgecase/ImportMetaMainTargetNodecaptures exactly this__require.main == __require.modulestring, proving the node target reaches this printer arm.
Why the existing arms don't catch it
EImportMeta(_)(line 1231) does not matchEImportMetaMain(_)— they are separateExprDatavariants. The prior review comment enumeratedEImportMetabut notEImportMetaMain, so both reviewer and author missed it. TheESpecial(_)arm coversE::Special::HotDisabled(which prints viaprint_undefined), but a directEUndefinednode reaches the wildcard.Fix
Add
| ExprData::EImportMetaMain(_) | ExprData::EUndefined(_)to the arm at line 1231. Over-wrappingdelete (0, void 0)underminify_syntaxis harmless per the comment at lines 1223-1224, so no gate is needed. Sibling tests:itBundled("edgecase/DeleteFoldedUndefinedRef", { files: { "/entry.js": `console.log(delete (null ?? undefined));` }, onAfterBundle: api => expect(api.readFile("out.js")).not.toMatch(/delete\s+undefined\b/), run: { stdout: "true" }, }); itBundled("edgecase/DeleteFoldedImportMetaMainRef", { files: { "/entry.js": `console.log(delete (null ?? import.meta.main));` }, onAfterBundle: api => expect(api.readFile("out.js")).not.toMatch(/delete\s+import\.meta\.main\b/), run: { stdout: "true" }, });
Per REVIEW.md ("Fix the whole class in the same PR — same-class sites are one concern"), these belong here since 14d0f4b's stated purpose was to cover exactly this class of siblings. Both are pre-existing on
main(as was everything 14d0f4b addressed) but live in the exact match arm this PR extends. -
🟡
src/js_printer/lib.rs:1231— TheEImportMeta(_)arm added in 14d0f4b has no test — deleting| ExprData::EImportMeta(_)breaks none of the 8 new tests (DeleteFoldedRequireRefscoversERequire*,DeleteFoldedInlinedConstEnumNaNcoversEInlinedEnum,DeleteFoldedModuleExportsRefcoversESpecial). Per REVIEW.md ("confirm deleting each load-bearing clause of your fix breaks at least one test") it needs one — an output-shape check under--format=cjs(whereimport.metaprints viaimport_meta_refas a bare symbol) orFormat::InternalBakeDev(where it prints as<hmr>.importMeta), mirroring theDeleteFoldedModuleExportsRefapproach.Extended reasoning...
What's missing
Commit 14d0f4b extended the match at src/js_printer/lib.rs:1225-1231 with four new variants alongside the original three, and added tests for three of them:
Arm Test ERequireCallTarget/ERequireResolveCallTarget/ERequireMainedgecase/DeleteFoldedRequireRefsEInlinedEnumedgecase/DeleteFoldedInlinedConstEnumNaNESpecialcjs2esm/DeleteFoldedModuleExportsRef(added after the prior review round)EImportMeta— none — Grepping the 8 new
DeleteFolded*tests forimport.metafinds nothing; the onlyimport.metahits inbundler_edgecase.test.tsare the pre-existingImportMetaMain*tests, which don't involvedelete. Removing| ExprData::EImportMeta(_)from line 1231 leaves every test in this PR green.Why the arm is load-bearing
At src/js_printer/lib.rs:2843-2867,
EImportMetaprints in one of three ways:Format::InternalBakeDev→<hmr_ref>.importMeta— a property access. Without the(0, ...)wrap,delete (null ?? import.meta)would emitdelete hmr.importMeta, actually deleting the runtime'simportMetaproperty instead of returningtruewith no effect.- CJS
import_meta_refpath (line 2866) →print_symbol(import_meta_ref)— a bare identifier.delete <ident>in module/strict code is an early SyntaxError, exactly the failure mode this PR fixes forEImportIdentifier. - default → literal
import.meta— also a MemberExpression;delete import.metais a runtime no-op returningtrue, so this path happens to be benign, but it doesn't cover the other two.
So the arm is not defensive padding; in at least two output modes the printed form is a Reference that
deletewould act on incorrectly.Why this is being raised
REVIEW.md's test rubric is explicit: "Confirm deleting each load-bearing clause of your fix breaks at least one test — a test that passes both ways is worse than no test." This exact rule was applied in the prior review round to the
ESpecial(_)arm, and the author complied by addingcjs2esm/DeleteFoldedModuleExportsRefas an output-shape-only check (because a runtime check wasn't feasible there).EImportMetawas added in the same follow-up commit and is subject to the same standard.Step-by-step proof
- Source:
console.log(delete (null ?? import.meta))in a CJS entry with--format=cjs(or under the bake dev-server format). - Parser: syntactic operand is
EBinary(??), soWAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESSis not set. - Visit pass folds
null ?? import.meta→EImportMeta(unconditional??fold, same as every other test in this PR). - Printer at lib.rs:4011: parse-time flag unset → consults
is_identifier_or_numeric_constant_or_property_access(&e.value). - With the
EImportMeta(_)arm: returnstrue→ emitsdelete (0, <import_meta_ref>)/delete (0, hmr.importMeta)→ runtime evaluates totrue, no side effect (correct). - Without the arm: falls through to
_ => false→ emitsdelete <import_meta_ref>(strict-mode SyntaxError) ordelete hmr.importMeta(deletes the property).
No test in this PR reaches step 3 with an
EImportMeta, so step 6 goes unobserved.Suggested test
An output-shape check mirroring
DeleteFoldedModuleExportsRefis the lightest option — e.g. a--format=cjsbundle assertingdelete (0, <import_meta_ref>)in the output andnot.toMatch(/delete\s+import_meta\b/), or atest/bake/dev-server case asserting the wrap aroundhmr.importMeta. Either would fail if| ExprData::EImportMeta(_)is removed.
delete (null ?? undefined) printed 'delete undefined' (strict-mode SyntaxError) because the visit pass substitutes unbound 'undefined' to EUndefined and print_undefined emits the bare identifier when not minifying. delete (null ?? import.meta.main) printed 'delete import.meta.main' (deletes the property) and under --target=node printed 'delete __require.main == __require.module', which binds delete to __require.main. Both are the same fold path as the other visit-pass siblings.
|
Addressed in 75fb43f: added That completes the On |
There was a problem hiding this comment.
LGTM — the two inline notes are pre-existing adjacent issues (print-time cross-module enum inlining under the flag-set path, and the EImportMetaMain node-target level handling), neither introduced nor widened here; both belong in separate PRs as noted.
Extended reasoning...
Overview
Extends is_identifier_or_numeric_constant_or_property_access (src/js_printer/lib.rs:1215) to recognise nine additional post-visit ExprData variants and to recurse through EInlinedEnum, so the printer's delete (0, …) re-wrap at :4015 fires when a fold like null ?? ns.x collapses onto a synthetic identifier/property-access node. Eleven new itBundled tests across bundler_edgecase.test.ts and bundler_cjs2esm.test.ts, each a fail-before/pass-after runtime or output-shape assertion.
Security risks
None. Pure printer-output correctness in a total helper with no I/O, allocation, or user-controlled parsing.
Level of scrutiny
Medium — bundler codegen correctness. The helper has exactly one call site, gated by !WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS, so every new arm can only add a (0, …) wrap where the source operand was not syntactically an identifier/dot/index. Over-wrapping is semantics-preserving (delete (0, <value>) → true); under-wrapping was the bug. The one known producer that violates the flag invariant (react_compiler/codegen.rs emitting UnDelete with empty flags over EDot/EIndex) is pre-existing on the untouched EDot | EIndex arm and was already handed off.
Other factors
This has been through two prior review rounds; all blocking feedback (missing ERequire*/EImportMeta/EInlinedEnum siblings, missing ESpecial test coverage, comment length, missing EImportMetaMain/EUndefined) was addressed across 14d0f4b → 75fb43f. The author's ExprData sweep and the coderabbit EImportMeta reasoning check out against the print arms. The two remaining inline findings this run are pre-existing bugs in code paths this diff does not touch (the flag-set EDot print-time enum-inlining branch, and the EImportMetaMain else-branch precedence handling) — flagged for tracking, not blocking.
There was a problem hiding this comment.
LGTM — monotone extension of the delete re-wrap classifier; can only add (0, ...) wraps on the flag-unset path, never remove them.
What was reviewed:
- Sole call site at lib.rs:4015 confirms the helper is gated on
!WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS, so over-matching (e.g.ESpecial::ResolvedSpecifierString,EImportMetain ESM) is a harmless extra wrap, never a semantic change. EInlinedEnumrecursion is bounded (wraps a leaf number/string) and correctly reaches the existingENumberNaN/Infinity check.- 11 new
itBundledtests cover each added arm with runtime + output-shape assertions; all prior review threads resolved, remaining findings are pre-existing and handed off.
Extended reasoning...
Overview
Extends is_identifier_or_numeric_constant_or_property_access in src/js_printer/lib.rs with nine additional ExprData variants (EImportIdentifier, ECommonjsExportIdentifier, ESpecial, ERequireCallTarget, ERequireResolveCallTarget, ERequireMain, EImportMeta, EImportMetaMain, EUndefined) plus recursion through EInlinedEnum. Adds 8 tests to bundler_edgecase.test.ts and 3 to bundler_cjs2esm.test.ts. Net +11 lines Rust, ~180 lines of tests.
Security risks
None. Pure printer output-shape change in the bundler; no user input parsing, no allocation, no FFI, no auth/crypto surface.
Level of scrutiny
Low-medium. The change is a match-arm extension with a strong safety invariant: the helper is only consulted at lib.rs:4015 when the parse-time WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS flag is unset — i.e., the source's delete operand was syntactically not a Reference. Returning true for additional node kinds can therefore only add a (0, ...) wrap, converting the printed operand from a possible Reference back to a value, which is exactly what the source semantics require. There is no path by which this change removes a wrap or turns a value-delete into a Reference-delete. I checked that variants which don't strictly print as an identifier/property-access (ESpecial::ResolvedSpecifierString → string literal, ESpecial::HotDisabled/EUndefined → void 0 when minified, EImportMeta → MetaProperty in ESM, EImportMetaMain → equality expr under node target) are all safe to over-wrap: delete (0, <expr>) is semantically identical to delete <non-Reference-expr>.
Other factors
- All seven review threads are resolved. Earlier rounds drove out missing test coverage (
ESpecial,EImportMetaMain,EUndefined) and the comment-length bot's complaints; three findings were explicitly classified as pre-existing (react_compiler codegen missing the flag, print-time cross-module enum inlining on the flag-set path,EImportMetaMainelse-branch missing a level-based paren wrap) and handed off — none are introduced or widened here. - Test coverage is thorough: each added arm has a dedicated test that fails on main (runtime SyntaxError or wrong property delete) and passes with the fix, plus negative output-shape regexes. The one output-shape-only test (
cjs2esm/DeleteFoldedModuleExportsRef) documents why it can't run the bundle (a separate pre-existing cjs2esm issue). - The
EInlinedEnumrecursion cannot loop: inlined-enum values are leafENumber/EStringnodes. - esbuild has the same
EImportIdentifiergap per the PR description, so this is a genuine correctness improvement over the reference implementation, not a divergence.
|
The diff is ready. Buildkite 87515 finished: 177 lanes passed including every bundler lane with the 11 new Local verification: all 11 new tests fail on main and pass with the fix; full |
|
Updated 5:00 PM PT - Aug 3rd, 2026
✅ @robobun, your commit fa5ee8d8322a037d58597e03fe4f000e4c6d0919 passed in 🧪 To try this PR locally: bunx bun-pr 36740That installs a local version of the PR into your bun-36740 --bun |
|
@robobun conflicts |
|
Merged main and resolved the |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/js_printer/lib.rs (1)
297-307: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winDeduplicate entries after
rename_key.
replace_string_idcan replaceold_idwith anew_idthat already exists. This can leave duplicate(specifier, type, phase)entries in the parallel vectors. Rebuildingindexonly overwrites the map entry and still serializes both records.Compact the vectors during the rebuild and retain the first entry to preserve insertion order. Add a regression test where two distinct IDs converge on one ID.
🤖 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_printer/lib.rs` around lines 297 - 307, Update the rebuild logic in the touched branch of the relevant printer method to deduplicate the parallel keys, values, and phases vectors by `(specifier, type, phase)` while rebuilding `self.index`, retaining the first occurrence to preserve insertion order. Ensure `replace_string_id`/`rename_key` convergence removes later duplicate records before serialization, and add a regression test covering two distinct IDs replaced by the same ID.
🤖 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_printer/lib.rs`:
- Around line 297-307: Update the rebuild logic in the touched branch of the
relevant printer method to deduplicate the parallel keys, values, and phases
vectors by `(specifier, type, phase)` while rebuilding `self.index`, retaining
the first occurrence to preserve insertion order. Ensure
`replace_string_id`/`rename_key` convergence removes later duplicate records
before serialization, and add a regression test covering two distinct IDs
replaced by the same ID.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 521d7565-ddbe-455a-bf9f-935acd8f719c
📒 Files selected for processing (1)
src/js_printer/lib.rs
|
@coderabbitai the |
|
Tip For best results, initiate chat on the files or code changes.
✏️ Learnings added
You are interacting with an AI system. |
There was a problem hiding this comment.
LGTM — conflicts are resolved (fa5ee8d merges main into bundler_edgecase.test.ts only) and the substantive change is unchanged since the last green CI run.
Checked that is_identifier_or_numeric_constant_or_property_access still has exactly one call site, gated on !WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS, so the new arms can only add (0, ...) wraps where the source's delete operand was never a Reference — over-wrap is a no-op true, never a semantics change. Swept the added ExprData variants against their printer arms; each prints as an identifier/member-access in at least one mode and none regress the flag-set path. The 11 new itBundled tests each pair a runtime assertion with a negative output-shape regex; ESpecial::ModuleExports is output-shape-only for a documented pre-existing reason. The three pre-existing siblings flagged earlier (react-compiler UnaryFlags::empty(), print-time cross-module enum delete NaN, EImportMetaMain node-target level-wrap) live on paths this helper cannot see and were handed off separately.
Extended reasoning...
Overview
An 11-line addition to is_identifier_or_numeric_constant_or_property_access in src/js_printer/lib.rs, plus 11 new itBundled tests across test/bundler/bundler_edgecase.test.ts (8) and test/bundler/bundler_cjs2esm.test.ts (3). The helper now recognises the visit-pass rewrite node kinds (EImportIdentifier, ECommonjsExportIdentifier, ESpecial, ERequire*, EImportMeta/EImportMetaMain, EUndefined) and recurses through EInlinedEnum, so a folded delete (null ?? ns.x) re-wraps as delete (0, x) instead of emitting delete x (a strict-mode SyntaxError in the ESM bundle).
Security risks
None. This is a pure code-printer correctness fix on the bundler output path; no untrusted-input parsing, no auth/crypto/permissions, no memory management.
Level of scrutiny
Medium — the printer is production-critical, but the change is narrowly scoped: the helper's single call site (lib.rs:4085) is guarded by e.op == UnDelete && !e.flags.contains(WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS), so the flag-set path (delete ns.x, delete require) is untouched and the change can only add (0, ...) wraps where the source operand was already a value expression. An over-wrap on any of the new arms is semantically a no-op (delete <value> → true), so the failure mode is bounded to a few extra output bytes, never a behaviour change. The EInlinedEnum recursion is bounded by AST depth (no self-nesting).
Other factors
- All prior review threads are resolved: my earlier
EImportMetaMain/EUndefinedgap was fixed in 75fb43f; the comment-cop nits were trimmed; CodeRabbit withdrew itsEImportMetatest request; the three 🟣 pre-existing siblings I flagged (react-compiler codegen, print-time cross-module enum inlining,EImportMetaMainnode-target level-wrap) live on paths this helper never sees and were correctly deferred to separate PRs. - Test coverage is thorough: each new variant has a dedicated test with both a negative output regex and a runtime
stdoutcheck (exceptDeleteFoldedModuleExportsRef, which is output-shape-only for a documented pre-existing reason). The PR description confirms fail-before/pass-after for all 11. - CI: build 87515 passed all bundler lanes on the pre-merge head; the only activity since is fa5ee8d, a mechanical merge of main resolving a
bundler_edgecase.test.tsadd/add conflict — no source changes. Jarred's only comment ("conflicts") has been addressed by that merge. - esbuild parity: the PR description notes esbuild has the same
EImportIdentifiergap, so this is a strict improvement over the reference implementation for the shared case.
…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 -->
Problem
delete (null ?? ns.x)evaluates its operand to a value, so per spec it returnstruewith no effect. The bundler correctly emitsdelete (0, ns.x)under--no-bundle, but when bundling the??folds away andns.xis rewritten to anEImportIdentifierpointing at the hoisted binding. The printer's(0, ...)re-wrap atsrc/js_printer/lib.rs:4004is driven byis_identifier_or_numeric_constant_or_property_access, which only recognisedEIdentifier | EDot | EIndexand so let theEImportIdentifierthrough as a baredelete x.The same gap applies to every visit-pass rewrite that can land as a
deleteoperand after a fold and prints as an identifier or property access:EImportIdentifierdelete (null ?? ns.x)(namespace import)delete xECommonjsExportIdentifierdelete (null ?? exports.a)under cjs2esmdelete $aESpecial::ModuleExportsdelete (null ?? module.exports)under cjs2esmdelete exports_entryERequireCallTargetdelete (null ?? require)delete __requireERequireMain/ERequireResolveCallTargetdelete (null ?? require.main)delete __require.mainEInlinedEnumwrappingNaN/Infinityconst enum E { N = 0/0 }; delete (null ?? E.N)delete NaNEUndefineddelete (null ?? undefined)(define substitution)delete undefinedEImportMetaMaindelete (null ?? import.meta.main)delete import.meta.main/delete __require.main == __require.moduleEImportMeta(bake dev / CJS ref)delete (null ?? import.meta)delete hmr.importMetaEach 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_ACCESSis set at parse time from the syntactic operand, so it is correctly unset fordelete (null ?? ns.x); the wrap just needs to recognise the post-visit node kinds.Fix
Extend
is_identifier_or_numeric_constant_or_property_accessto matchEImportIdentifier | ECommonjsExportIdentifier | ESpecial | ERequireCallTarget | ERequireResolveCallTarget | ERequireMain | EImportMeta | EImportMetaMain | EUndefined, and recurse throughEInlinedEnumso the existingNaN/Infinitycheck reaches the wrapped value. The helper is only consulted when the parse-time flag is not set, sodelete 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
isIdentifierOrNumericConstantOrPropertyAccesshas the sameEImportIdentifiergap andesbuild --bundlemiscompiles 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_targetlive in the visitor):ns.xinside(null ?? ns.x)is not the delete target there either, so that PR does not cover this path.Verification
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.tsunchanged.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