Skip to content

js_printer: print void 0 for the value undefined under delete - #45526

Open
robobun wants to merge 3 commits into
mainfrom
robobun/4e5e3c84/delete-define-stale-flag
Open

robobun wants to merge 3 commits into
mainfrom
robobun/4e5e3c84/delete-define-stale-flag

Conversation

@robobun

@robobun robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --define process.env.DEBUG=undefined turns delete process.env.DEBUG into delete undefined. An ES module with that line fails to load: SyntaxError: Cannot delete unqualified property 'undefined' in strict mode. The debug package has this line.
  • The printer prints a delete operand bare when the flag WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS is set (src/js_printer/lib.rs:4446). The define made the operand the value undefined, printed as the identifier undefined.

Fix

  • The EUnary arm prints void 0 for an undefined operand of delete, with or without the flag. esbuild prints the same bytes.
  • The rule holds for each producer: a define, the import.meta.hot fold, a macro result.
  • Verified: test/bundler/transpiler/transpiler.test.js, test/bundler/esbuild/extra.test.ts. More suites in Notes.
  • Self-reviewed: 22 concerns raised, 15 addressed. Notes explains the other 7.

Background

  • delete x deletes a binding. Strict code forbids it. delete <value> evaluates the value and returns true. undefined is an identifier.
  • Without the flag the printer already wrote delete (0, undefined) (js_printer: re-wrap delete of folded import/cjs-export identifier #36740).
  • Considered: clear the flag in the visit pass (a second classifier, blind to print-time rewrites), and no substitution under delete (a browser bundle keeps a live process reference).

Downsides

  • NaN and Infinity define values still print delete NaN and delete Infinity, as esbuild does. Strict code rejects both.
  • Under --inspect, sloppy const x = undefined; delete x now gives true. Node gives false. The default runtime already gave true.
  • Cost: +5.0 instructions per printed delete (of about 3,128), 0 allocations, +512 bytes, one transpiler cache refill.
Notes

Repro

$ printf 'delete process.env.DEBUG;\nconsole.log("loaded");\n' > entry.mjs
$ bun build ./entry.mjs --target=bun --define 'process.env.DEBUG=undefined' --outfile out.mjs && bun out.mjs
SyntaxError: Cannot delete unqualified property 'undefined' in strict mode.

After the change out.mjs has delete void 0; and prints loaded. esbuild 0.21.5 and 0.28.2 print delete void 0; for the same input. In cjs and iife output the old line loads and gives false, where the source gives true.

Output that changes (release builds of main 116dd64 and this PR, Bun.Transpiler and bundles in esm and cjs)

Source main this PR
delete a.b, define a.b=undefined delete undefined delete void 0
delete import.meta.hot, HMR off delete undefined delete void 0
delete config().missing, macro result delete undefined delete void 0
delete void 0, delete (null ?? undefined), delete (null ?? a.b) delete (0, undefined) delete void 0
the same three with --minify-syntax and in the default runtime delete (0, void 0) delete void 0

The first three rows did not load in strict code. The last two rows loaded and gave true, and still do. With --minify-syntax the first three rows already printed delete void 0.

Not changed, with the reason

Overlap with open PRs

Design history

The first version of this branch cleared the flag in the UnDelete arm of e_unary when the operand came back as a primitive value, with a classifier over each expression kind (106 source lines). The self-review rejected it: the printer is the only code that spells a value as undefined, it already has the predicate from #36740, and the visit pass cannot see print-time rewrites. This version is 24 added and 8 removed source lines, 22 and 7 of them in the printer.

Self-review concerns not addressed: a generated position-by-operand differential test (it belongs with the assignment targets in #30936), an upstream esbuild report for delete NaN, the const-inlining gate (a separate change), a redesign of the [x][n] fold, and a test through --inspect.

Measurements (release builds of main 116dd64 and the PR, linux x64. instructions:u from a perf_event_open wrapper, 10 interleaved runs each, run-to-run spread about 1,000)

  • release binary: print_expr +98 to +148 bytes in each of 5 copies, print_undefined and visit_expr_in_out unchanged, text +512 bytes (nm, size)
  • typescript.js --no-bundle: -2,094 instructions of 950,788,294. With --minify-syntax: -2,001 of 973,894,077
  • 200,000 x = !o.p;: -32. 200,000 x = typeof o.p;: +290
  • 200,000 delete o.p;: +999,936, so +5.0 per statement of about 3,128
  • allocations: 1,530 -> 1,530 on 200,000 plain deletes, 1,558 -> 1,558 on 200,000 define hits (gdb hit counts on the 11 mimalloc entry points. strace, perf and valgrind are not installed)
  • unaffected output: 0 differing bytes over 12 builds, 48.4 MB (cmp): typescript.js, prettier, a React app with the default defines, esbuild's lib/main.js
  • transpiler cache: version 35 -> 36. bun-v1.4.3 has 34, so users of the last release refill once with or without this PR. One refill of a 9.1 MB module costs 1.28e9 instructions

Suites run with the debug build, all green

transpiler/transpiler.test.js, esbuild/extra.test.ts, bundler_minify, bundler_cjs2esm, bundler_drop, transpiler/react-compiler, transpiler/assign-to-import, transpiler/macro-test, transpiler/runtime-transpiler, esbuild/ts, esbuild/dce, cli/run/transpiler-cache, and the edgecase/Delete* cases of bundler_edgecase.

`delete a.b` printed `delete undefined` when a define, a fold or a macro
replaced `a.b` with the value undefined. That form deletes an identifier:
a SyntaxError in strict code, `false` in sloppy code. esbuild prints
`delete void 0` here, and so does bun with --minify-syntax.

The printer now prints `void 0` for an undefined operand of `delete`,
whatever the operand was in the source. The runtime transpiler cache
version is 36.
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: a598a2a4-b640-445c-9401-be03380eab89
📥 Commits

Reviewing files that changed from the base of the PR and between 1421e6f and 9a772bd.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: b53b5eca-a853-4b45-ace6-e9476f78b8f7
📥 Commits

Reviewing files that changed from the base of the PR and between 5866b5f and 1421e6f.

📒 Files selected for processing: 4
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/esbuild/extra.test.ts
  • test/bundler/transpiler/transpiler.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

The printer now emits delete void 0 when a delete operand is an undefined value. Other eligible operands retain the (0, operand) form. The runtime transpiler cache format version increases to 36, and transpiler and bundler tests cover the output.

Changes

Delete Undefined Output

Layer / File(s) Summary
Printer behavior and cache version
src/js_printer/lib.rs, src/jsc/RuntimeTranspilerCache.rs
The printer recognizes EUndefined and HotDisabled as undefined values. Delete expressions for these values emit void 0; other eligible operands retain the (0, operand) form. The cache format version increases from 35 to 36.
Transpiler and bundler coverage
test/bundler/esbuild/extra.test.ts, test/bundler/transpiler/transpiler.test.js
Tests cover undefined delete operands and distinguish value operands from references across bundler formats, loaders, and minification settings.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 1421e

This change makes delete of a value that evaluates to undefined print delete void 0, which fixes strict-mode load failures. No concrete merge-blocking risk was identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely identifies the main change: printing void 0 for the value undefined in delete expressions.
Description check Passed The description explains the problem, fix, affected cases, limitations, performance impact, and verification. It provides more detail than the template requires and covers both requested areas.
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:45 AM PT - Oct 17th, 2026

✅ @robobun, your commit 9a772bd6bee074a1c20e7d35d8a41f2aa362fc20 passed in Build #127440! 🎉


🧪   To try this PR locally:

bunx bun-pr 45526

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

bun-45526 --bun

@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it (release build of main 116dd64, and bun 1.4.2):

$ printf 'delete process.env.DEBUG;\nconsole.log("loaded");\n' > entry.mjs
$ bun build ./entry.mjs --target=bun --define 'process.env.DEBUG=undefined' --outfile out.mjs && bun out.mjs
SyntaxError: Cannot delete unqualified property 'undefined' in strict mode.

out.mjs contains delete undefined;. With this branch it contains delete void 0; and the command prints loaded. esbuild 0.28.2 prints delete void 0; for the same input.

PR: #45526

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the other consumers of is_identifier_or_numeric_constant_or_property_access after EUndefined was dropped from it: the only callers are the delete arm at src/js_printer/lib.rs:4446 (where is_undefined_value is tested first) and its own EInlinedEnum recursion, so no other print path changes. The delete ns.missing namespace-import case and the cache-refill cost were also looked at and are pre-existing / expected, not regressions from this diff.

Extended reasoning...

The change is a small, focused edit to the prefix-unary print path in src/js_printer/lib.rs plus a transpiler cache version bump; it touches no security-sensitive surface. Three inline findings were posted (a sloppy-mode const-inlining result change, a test that cannot distinguish which producer regressed, and the pre-existing NaN/Infinity gap), so a human should weigh those before merging.

Comment thread src/js_printer/lib.rs
// "delete undefined" deletes an identifier, whatever the operand was in the source
self.print_space_before_identifier();
self.add_source_mapping(e.value.loc);
self.print(b"void 0");

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.

🟡 (optional) Sloppy CommonJS code transpiled with const inlining but without minify syntax now gets true from delete x where base and Node give false. Bun.Transpiler({inline:true}) and the runtime under --inspect inline const x = undefined at src/js_parser/p.rs:2210, so the delete operand becomes EUndefined; src/js_printer/lib.rs:4448-4452 then prints delete void 0 where the base printed delete undefined (false, matching the source). Fix: do not inline a const whose use is the delete target (gate at p.rs:2209 on opts.is_delete_target()), so every value class (undefined, numbers, strings) keeps the source's reference semantics under delete.

Why this was flagged

Trigger: sloppy-mode source const x = undefined; console.log(delete x) passed through Bun.Transpiler with inline:true and no minify.syntax, or through the runtime transpiler in a configuration with inlining on and minify_syntax off (the PR text names --inspect). handle_identifier at src/js_parser/p.rs:2209-2214 replaces the identifier with the const value even when opts.is_delete_target() is set, giving EUnary(delete, EUndefined) with the WAS_ORIGINALLY_DELETE flag. On base the printer emitted delete undefined, which in sloppy mode returns false (the global undefined is non-configurable), matching the source's false for deleting a const binding. After this change lib.rs:4448-4452 emits delete void 0, which returns true. The dismissal relied on the author calling it an accepted downside and on minify-syntax output already being void 0; but the non-minify inlining population is a real configuration and the result flips from correct to incorrect. Remedy: skip const inlining for a delete target, which also keeps delete x for inlined numbers/strings consistent.

Verification: Trigger: sloppy-mode source const x = undefined; delete x transpiled with inlining on and minify_syntax off. handle_identifier at src/js_parser/p.rs:2209-2214 returns the inlined value before any is_delete_target() check. On base the printer wrote delete undefined, which evaluates to false. After the diff, src/js_printer/lib.rs:4446-4452 writes delete void 0, which evaluates to true.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct. The PR body lists this under Downsides. The cause is the const inlining in handle_identifier, which has no delete check. On main the default runtime already prints delete void 0 for this input and returns true, and const x = 1; delete x returns true there too. A gate on that inlining changes the default runtime result, so it is a separate change and not part of this printer rule.

Comment thread test/bundler/esbuild/extra.test.ts Resolved
Comment thread src/js_printer/lib.rs
Comment on lines 4443 to +4446
if Op::Code::is_prefix(e.op) {
// Never turn "typeof (0, x)" into "typeof x" or "delete (0, x)" into "delete x"
if (e.op == Op::Code::UnTypeof && !e.flags.contains(E::UnaryFlags::WAS_ORIGINALLY_TYPEOF_IDENTIFIER) && self.is_unbound_identifier(&e.value))
|| (e.op == Op::Code::UnDelete && !e.flags.contains(E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS) && is_identifier_or_numeric_constant_or_property_access(&e.value))
|| (e.op == Op::Code::UnDelete && (is_undefined_value(&e.value) || (!e.flags.contains(E::UnaryFlags::WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS) && is_identifier_or_numeric_constant_or_property_access(&e.value))))

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.

🟣 pre-existing, not blocking: pre-existing: a bundled ES module still fails to load when a define, macro or inlined enum puts the value NaN or Infinity under delete. The UnDelete arm at src/js_printer/lib.rs:4446 only wraps a non-finite ENumber when WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS is clear, so delete a.b with a macro result {b: NaN} prints delete NaN, a strict-mode SyntaxError. Fix: treat every operand the printer spells as a bare identifier (undefined, NaN, Infinity) the same way under delete regardless of the flag, e.g. extend the new value branch to non-finite ENumber and --define x=NaN/Infinity identifier defines, printing (0, NaN) or void 0.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input: delete config().v where the macro returns { v: NaN }, or delete a.b with --define a.b=NaN (src/bundler/defines.rs:365 keeps NaN as an EIdentifier), in an ESM output or any strict-mode file. The parser sets WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS at src/js_parser/parse/parse_prefix.rs:419 because the source operand was an EDot. In the printer, src/js_printer/lib.rs:4446 fires the wrapper only when is_undefined_value is true or the flag is clear, so the operand is printed bare via print_number (src/js_printer/lib.rs:6912 prints NaN, 6932 prints Infinity in non-minified bundles). Output is delete NaN, which JavaScriptCore rejects in strict code with a SyntaxError and which returns false in sloppy code, whereas the source expression returned true. The base branch behaves identically; this PR fixes only the undefined member of the same class, and the PR text lists the NaN/Infinity cases as deliberately left out citing esbuild parity.

Verification: pre-existing; acknowledged in diff: the PR description's "Not changed, with the reason" section states that NaN/Infinity define values under delete still print delete NaN; the base already fails by the same route and this PR neither widens nor fixes it. At src/js_printer/lib.rs:4446 the wrapper fires only for is_undefined_value or a clear flag, so ENumber(NaN) with the flag set falls to the bare self.print_expr(...) branch at lib.rs:4460.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Left out on purpose. For these values esbuild prints delete NaN and delete Infinity too, so a fix leaves the bytes of the reference and needs a maintainer decision first. A number arm that ignores the flag also turns const x = NaN; delete x from false into true in the default runtime, until const inlining is gated under delete. The PR notes give the follow-up shape.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The second push addresses the per-producer assertion point from my earlier review, and this run found no new bugs; a human look is still worthwhile because this changes printed bundler output for every delete operand the printer spells as undefined, and the PR knowingly accepts a runtime-semantics difference under const inlining (--inspect) that a maintainer should sign off on.

What was reviewed:

  • The UnDelete arm and the new is_undefined_value helper against print_undefined and the ESpecial::HotDisabled arm (which delegates to EUndefined), so the helper covers both shapes that print as the bare identifier; the only other caller of the trimmed predicate is this same arm, and the new check runs first, so no other output changes.
  • A fourth candidate producer, a bundled delete ns.missing on a namespace import (an EImportIdentifier with Missing status prints undefined at src/js_printer/lib.rs:4293): ruled out, because the parser rejects any delete target bound to an import symbol with Cannot assign to import before the linker can mark it missing, and dynamic-import locals leave delete targets as EDot.
  • The transpiler cache version bump to 36 is present; CODEOWNERS covers none of the changed paths.
Extended reasoning...

The change touches the JS printer's prefix-unary arm (src/js_printer/lib.rs), bumps the runtime transpiler cache version in src/jsc/RuntimeTranspilerCache.rs, and adds exact-output tests in test/bundler/esbuild/extra.test.ts and test/bundler/transpiler/transpiler.test.js covering define, import.meta define, macro and import.meta.hot producers across esm/cjs/iife and minify settings. It touches no security-sensitive surface. The second commit addressed my prior inline comment about asserting the printed line per producer; the other two prior comments were optional or pre-existing and the PR text declines them with reasons. Deferring rather than approving because it is a semantic change to bundler output with an author-acknowledged behavior difference under const inlining, which is a maintainer-level design call even though the diff itself is small and I found no bugs in it.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

This branch has not been deployed

No deployments
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