From 70e191edea4b8a0cee39ab45050323ce2226ba52 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:59:22 +0000 Subject: [PATCH 01/10] js_parser: set p.delete_target before visiting the delete operand 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 ` 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. --- src/js_parser/visit/visit_expr.rs | 10 +++- src/jsc/RuntimeTranspilerCache.rs | 5 +- test/bundler/bundler_cjs2esm.test.ts | 30 +++++++++++ .../transpiler/assign-to-import.test.ts | 11 ++++ test/bundler/transpiler/transpiler.test.js | 51 +++++++++++++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 0d5c834fde53..4e8c1859e8ed 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1057,7 +1057,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = e_.target.unwrap_inlined(); let index = e_.index.unwrap_inlined(); - if p.options.features.minify_syntax { + // `a[n]` is a property reference; replacing it with a value changes + // the result of `delete` (reference → value makes it return `true`) + // and of assignment (writes to the wrong place). Neither `"foo"[2]` + // → `"o"` nor `[x][0]` → `x` is safe in those positions. + if p.options.features.minify_syntax + && !is_delete_target + && in_.assign_target == js_ast::AssignTarget::None + { if let Some(number) = index.data.as_e_number() { if number.value() >= 0.0 && number.value() < (usize::MAX as f64) @@ -1218,6 +1225,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } Op::UnDelete => { + p.delete_target = e_.value.data; p.visit_expr_in_out(&mut e_.value, ExprIn::default()); } _ => { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ab204c146078..c3a6f2d1e1e7 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -48,7 +48,10 @@ bun_core::declare_scope!(cache, visible); /// bindings from the compiled bytecode after the module-loader rewrite, so the /// record no longer carries them; blobs written in the old numbering must not /// be read back. -const EXPECTED_VERSION: u32 = 24; +/// Version 25: `p.delete_target` is now set before visiting the `delete` +/// operand, so `delete E.Member` / `delete {f:x}.f` / `delete [x][0]` / +/// `delete "s"[n]` no longer fold to a value under `minify_syntax`. +const EXPECTED_VERSION: u32 = 25; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/bundler/bundler_cjs2esm.test.ts b/test/bundler/bundler_cjs2esm.test.ts index 5e2e81692573..db20d257daee 100644 --- a/test/bundler/bundler_cjs2esm.test.ts +++ b/test/bundler/bundler_cjs2esm.test.ts @@ -392,6 +392,36 @@ describe("bundler", () => { stdout: '[[{"xyz":456},456],[{"xyz":123},123],[{"xyz":456},456],[{"xyz":123},123]]', }, }); + itBundled("cjs2esm/DeleteExportsPropertyDeopt", { + files: { + "/entry.js": /* js */ ` + import * as lib from './lib.js'; + console.log(lib.a, lib.b); + `, + "/lib.js": /* js */ ` + exports.a = 1; + exports.b = 2; + delete exports.a; + `, + }, + cjs2esm: { unhandled: ["/lib.js"] }, + run: { stdout: "undefined 2" }, + }); + itBundled("cjs2esm/DeleteModuleExportsPropertyDeopt", { + files: { + "/entry.js": /* js */ ` + import * as lib from './lib.js'; + console.log(lib.a, lib.b); + `, + "/lib.js": /* js */ ` + module.exports.a = 1; + module.exports.b = 2; + delete module.exports.a; + `, + }, + cjs2esm: { unhandled: ["/lib.js"] }, + run: { stdout: "undefined 2" }, + }); // https://github.com/oven-sh/bun/issues/4565 // `exports.x = ...` as the unbraced body of if/while/do/else must not be // converted to `var $x = ...; export { $x as x };` because `export` is only diff --git a/test/bundler/transpiler/assign-to-import.test.ts b/test/bundler/transpiler/assign-to-import.test.ts index 655ec7f1c997..80e255ff75ba 100644 --- a/test/bundler/transpiler/assign-to-import.test.ts +++ b/test/bundler/transpiler/assign-to-import.test.ts @@ -81,6 +81,17 @@ describe("assigning to an imported binding", () => { `import * as ns from "./m.mjs"; const k = "x"; ns[k] = 5;\n`, 'Cannot assign to property on import "ns"', ], + ["delete namespace property", `import * as ns from "./m.mjs"; delete ns.x;\n`, 'Cannot assign to import "x"'], + [ + "delete string-index namespace property", + `import * as ns from "./m.mjs"; delete ns["x"];\n`, + 'Cannot assign to import "x"', + ], + [ + "delete computed namespace property", + `import * as ns from "./m.mjs"; const k = "x"; delete ns[k];\n`, + 'Cannot assign to property on import "ns"', + ], ])("bun build still rejects it: %s", async (_name, entry, diagnostic) => { using dir = tempDir("assign-to-import-build", { "m.mjs": mod, diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index d72bcc6fddcd..e9a8a56ccb64 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -139,6 +139,57 @@ describe("Bun.Transpiler", () => { it("works nested", () => { ts.expectPrintedMin_('const a = ["hey"][0][0];', 'const a = "h"'); }); + it("bails out when the index is a delete or assignment target", () => { + // `a[n]` is a property reference; folding it to a value changes the + // result of `delete` and the effect of assignment. + ts.expectPrintedMin_("x = delete [y][0]", "x = delete [y][0]"); + ts.expectPrintedMin_("x = delete [y.z][0]", "x = delete [y.z][0]"); + ts.expectPrintedMin_("x = delete { f: y }.f", "x = delete { f: y }.f"); + ts.expectPrintedMin_("x = delete { f: y }['f']", "x = delete { f: y }.f"); + ts.expectPrintedMin_('x = delete "foo"[2]', 'x = delete "foo"[2]'); + ts.expectPrintedMin_("x = [y][0] = 5", "x = [y][0] = 5"); + ts.expectPrintedMin_("x = [y][0] += 5", "x = [y][0] += 5"); + ts.expectPrintedMin_("[y][0]++", "[y][0]++"); + ts.expectPrintedMin_('x = "foo"[2] = 5', 'x = "foo"[2] = 5'); + // Still inlined outside those positions. + ts.expectPrintedMin_("x = [y][0]", "x = y"); + ts.expectPrintedMin_('x = "foo"[2]', 'x = "o"'); + ts.expectPrintedMin_("x = delete [y][0].z", "x = delete y.z"); + }); + it("does not inline an enum member under delete", () => { + const pre = "enum E { A = 1 }\n"; + const lastLine = out => out.trimEnd().split("\n").at(-1); + expect(lastLine(ts.parsed(pre + "x = delete E.A;", false))).toBe("x = delete E.A;"); + expect(lastLine(ts.parsed(pre + 'x = delete E["A"];', false))).toBe('x = delete E["A"];'); + expect(lastLine(ts.parsedMin(pre + "x = delete E.A;", false))).toBe("x = delete E.A;"); + // Still inlined when read. + expect(lastLine(ts.parsed(pre + "x = E.A;", false))).toBe("x = 1 /* A */;"); + }); + it("does not substitute --define for a delete target", () => { + // `user_undefined` is defined as `undefined` in the transpiler config above. + ts.expectPrintedMin_("x = delete user_undefined", "x = delete user_undefined"); + // A later read is still substituted (delete_target is per-node, not per-symbol). + ts.expectPrintedMin_( + "x = delete user_undefined; y = user_undefined;", + "x = delete user_undefined;\ny = void 0;\n", + ); + }); + it("preserves delete/assign semantics at runtime", async () => { + const src = ` + var obj = { p: 1 }; + var r1 = delete [obj.p][0]; + var y = 1; + [y][0] = 5; + var k = { f: 7 }; + var r2 = delete { f: k.f }.f; + console.log(JSON.stringify([obj.p, r1, y, k.f, r2])); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("[1,true,1,7,true]\n"); + expect(exitCode).toBe(0); + }); it("bails out on optional-chain index into enum", () => { const pre = "enum Foo { A }\nenum Bar { 'a-b' = 1 }\n"; const lastLine = out => out.trimEnd().split("\n").at(-1); From ab3843d0ddab5515207cee32f17daa78a19d96a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:21:36 +0000 Subject: [PATCH 02/10] js_parser: also bail the [x][n] fold when it's a call target [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. --- src/js_parser/visit/visit_expr.rs | 9 ++++++--- test/bundler/transpiler/transpiler.test.js | 16 +++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 4e8c1859e8ed..839c0091a97e 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1058,11 +1058,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let index = e_.index.unwrap_inlined(); // `a[n]` is a property reference; replacing it with a value changes - // the result of `delete` (reference → value makes it return `true`) - // and of assignment (writes to the wrong place). Neither `"foo"[2]` - // → `"o"` nor `[x][0]` → `x` is safe in those positions. + // the result of `delete` (reference → value makes it return `true`), + // the effect of assignment (writes to the wrong place), and the + // receiver of a call (`[o.m][0]()` has `this = [o.m]`, not `o`). + // Neither `"foo"[2]` → `"o"` nor `[x][0]` → `x` is safe in those + // positions. if p.options.features.minify_syntax && !is_delete_target + && !is_call_target && in_.assign_target == js_ast::AssignTarget::None { if let Some(number) = index.data.as_e_number() { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index e9a8a56ccb64..53d8990a264f 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -139,9 +139,10 @@ describe("Bun.Transpiler", () => { it("works nested", () => { ts.expectPrintedMin_('const a = ["hey"][0][0];', 'const a = "h"'); }); - it("bails out when the index is a delete or assignment target", () => { + it("bails out when the index is a delete/assign/call target", () => { // `a[n]` is a property reference; folding it to a value changes the - // result of `delete` and the effect of assignment. + // result of `delete`, the effect of assignment, and the `this` + // binding of a call. ts.expectPrintedMin_("x = delete [y][0]", "x = delete [y][0]"); ts.expectPrintedMin_("x = delete [y.z][0]", "x = delete [y.z][0]"); ts.expectPrintedMin_("x = delete { f: y }.f", "x = delete { f: y }.f"); @@ -151,10 +152,13 @@ describe("Bun.Transpiler", () => { ts.expectPrintedMin_("x = [y][0] += 5", "x = [y][0] += 5"); ts.expectPrintedMin_("[y][0]++", "[y][0]++"); ts.expectPrintedMin_('x = "foo"[2] = 5', 'x = "foo"[2] = 5'); + ts.expectPrintedMin_("x = [y.z][0]()", "x = [y.z][0]()"); + ts.expectPrintedMin_("x = [y][0]()", "x = [y][0]()"); // Still inlined outside those positions. ts.expectPrintedMin_("x = [y][0]", "x = y"); ts.expectPrintedMin_('x = "foo"[2]', 'x = "o"'); ts.expectPrintedMin_("x = delete [y][0].z", "x = delete y.z"); + ts.expectPrintedMin_("x = f([y][0])", "x = f(y)"); }); it("does not inline an enum member under delete", () => { const pre = "enum E { A = 1 }\n"; @@ -174,7 +178,7 @@ describe("Bun.Transpiler", () => { "x = delete user_undefined;\ny = void 0;\n", ); }); - it("preserves delete/assign semantics at runtime", async () => { + it("preserves delete/assign/call-receiver semantics at runtime", async () => { const src = ` var obj = { p: 1 }; var r1 = delete [obj.p][0]; @@ -182,12 +186,14 @@ describe("Bun.Transpiler", () => { [y][0] = 5; var k = { f: 7 }; var r2 = delete { f: k.f }.f; - console.log(JSON.stringify([obj.p, r1, y, k.f, r2])); + var o = { m() { return this === o } }; + var r3 = [o.m][0](); + console.log(JSON.stringify([obj.p, r1, y, k.f, r2, r3])); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("[1,true,1,7,true]\n"); + expect(stdout).toBe("[1,true,1,7,true,false]\n"); expect(exitCode).toBe(0); }); it("bails out on optional-chain index into enum", () => { From 8a7c9f589fbb5561d9e2310ec698b7e411c2aeb4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:22:30 +0000 Subject: [PATCH 03/10] trim comments --- src/js_parser/visit/visit_expr.rs | 8 ++------ src/jsc/RuntimeTranspilerCache.rs | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 839c0091a97e..d508f4ebe6a3 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1057,12 +1057,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let target = e_.target.unwrap_inlined(); let index = e_.index.unwrap_inlined(); - // `a[n]` is a property reference; replacing it with a value changes - // the result of `delete` (reference → value makes it return `true`), - // the effect of assignment (writes to the wrong place), and the - // receiver of a call (`[o.m][0]()` has `this = [o.m]`, not `o`). - // Neither `"foo"[2]` → `"o"` nor `[x][0]` → `x` is safe in those - // positions. + // Folding a property reference to a value is unsafe where the + // reference itself is observed (delete result, assign target, call receiver). if p.options.features.minify_syntax && !is_delete_target && !is_call_target diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index c3a6f2d1e1e7..0b7ff49d8d19 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -48,9 +48,7 @@ bun_core::declare_scope!(cache, visible); /// bindings from the compiled bytecode after the module-loader rewrite, so the /// record no longer carries them; blobs written in the old numbering must not /// be read back. -/// Version 25: `p.delete_target` is now set before visiting the `delete` -/// operand, so `delete E.Member` / `delete {f:x}.f` / `delete [x][0]` / -/// `delete "s"[n]` no longer fold to a value under `minify_syntax`. +/// Version 25: `delete`/assign/call targets no longer fold under minify_syntax. const EXPECTED_VERSION: u32 = 25; /// Source files smaller than this are not written to / read from the on-disk From 834572177494c1e221f3c8ea786bf2d2ba39ad1d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:49:43 +0000 Subject: [PATCH 04/10] js_parser: don't inline import.meta. under delete/assign 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. --- src/js_parser/fold.rs | 14 +++++++++----- test/bundler/transpiler/transpiler.test.js | 11 +++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 085318aaa987..e13e4e46f025 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -492,11 +492,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } js_ast::ExprData::EImportMeta(_) => { - if name == b"main" { + let can_inline = !identifier_opts.is_delete_target() + && identifier_opts.assign_target() == js_ast::AssignTarget::None; + + if can_inline && name == b"main" { return Some(p.value_for_import_meta_main(false, target.loc)); } - if name == b"hot" { + if can_inline && name == b"hot" { return Some(Expr { data: js_ast::ExprData::ESpecial( if p.options.features.hot_module_reloading { @@ -510,9 +513,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } // Inline import.meta properties for Bake - if p.options.framework.is_some() - || (p.options.bundle - && p.options.output_format == js_parser::options::Format::Cjs) + if can_inline + && (p.options.framework.is_some() + || (p.options.bundle + && p.options.output_format == js_parser::options::Format::Cjs)) { if name == b"dir" || name == b"dirname" { // Inline import.meta.dir diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 53d8990a264f..1230348ed8d3 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -166,6 +166,7 @@ describe("Bun.Transpiler", () => { expect(lastLine(ts.parsed(pre + "x = delete E.A;", false))).toBe("x = delete E.A;"); expect(lastLine(ts.parsed(pre + 'x = delete E["A"];', false))).toBe('x = delete E["A"];'); expect(lastLine(ts.parsedMin(pre + "x = delete E.A;", false))).toBe("x = delete E.A;"); + expect(lastLine(ts.parsedMin(pre + 'x = delete E["A"];', false))).toBe("x = delete E.A;"); // Still inlined when read. expect(lastLine(ts.parsed(pre + "x = E.A;", false))).toBe("x = 1 /* A */;"); }); @@ -178,6 +179,16 @@ describe("Bun.Transpiler", () => { "x = delete user_undefined;\ny = void 0;\n", ); }); + it("does not inline import.meta. under delete or assignment", () => { + // Inlining would produce `delete undefined` (strict-mode SyntaxError) or + // `undefined = 5` for the HotDisabled value. + ts.expectPrinted_("x = delete import.meta.hot", "x = delete import.meta.hot"); + ts.expectPrinted_("x = delete import.meta.main", "x = delete import.meta.main"); + ts.expectPrinted_("import.meta.hot = 5", "import.meta.hot = 5"); + ts.expectPrinted_("import.meta.main = 5", "import.meta.main = 5"); + // Reads are still inlined. + ts.expectPrinted_("x = import.meta.hot", "x = undefined"); + }); it("preserves delete/assign/call-receiver semantics at runtime", async () => { const src = ` var obj = { p: 1 }; From d7f60ac0515a334dec88beacb6fc933e209c0eab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:56:37 +0000 Subject: [PATCH 05/10] js_parser: gate "str".length fold on delete/assign target `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. --- src/js_parser/fold.rs | 5 ++++- test/bundler/transpiler/transpiler.test.js | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index e13e4e46f025..8d41251b4c30 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -443,7 +443,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } js_ast::ExprData::EString(str_) => { - if p.options.features.minify_syntax { + if p.options.features.minify_syntax + && !identifier_opts.is_delete_target() + && identifier_opts.assign_target() == js_ast::AssignTarget::None + { // minify "long-string".length to 11 if name == b"length" { if let Some(len) = e_string_javascript_length(&str_) { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 1230348ed8d3..b7750324fb8e 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -148,15 +148,21 @@ describe("Bun.Transpiler", () => { ts.expectPrintedMin_("x = delete { f: y }.f", "x = delete { f: y }.f"); ts.expectPrintedMin_("x = delete { f: y }['f']", "x = delete { f: y }.f"); ts.expectPrintedMin_('x = delete "foo"[2]', 'x = delete "foo"[2]'); + ts.expectPrintedMin_('x = delete "foo".length', 'x = delete "foo".length'); ts.expectPrintedMin_("x = [y][0] = 5", "x = [y][0] = 5"); ts.expectPrintedMin_("x = [y][0] += 5", "x = [y][0] += 5"); ts.expectPrintedMin_("[y][0]++", "[y][0]++"); ts.expectPrintedMin_('x = "foo"[2] = 5', 'x = "foo"[2] = 5'); ts.expectPrintedMin_("x = [y.z][0]()", "x = [y.z][0]()"); ts.expectPrintedMin_("x = [y][0]()", "x = [y][0]()"); + // A conditional operand is not a reference; when a fold hoists the + // live branch up to the delete, the printer wraps it. + ts.expectPrinted_("x = delete (true ? a.b : 0)", "x = delete (0, a.b)"); + ts.expectPrinted_("x = delete (true ? a : 0)", "x = delete (0, a)"); // Still inlined outside those positions. ts.expectPrintedMin_("x = [y][0]", "x = y"); ts.expectPrintedMin_('x = "foo"[2]', 'x = "o"'); + ts.expectPrintedMin_('x = "foo".length', "x = 3"); ts.expectPrintedMin_("x = delete [y][0].z", "x = delete y.z"); ts.expectPrintedMin_("x = f([y][0])", "x = f(y)"); }); From 7a5376fc077ac6300369b635b9340adb2232b46b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:21:21 +0000 Subject: [PATCH 06/10] js_parser: gate import.meta.hot. arm on delete/assign; lock in 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. --- src/js_parser/fold.rs | 5 +++++ test/bundler/transpiler/transpiler.test.js | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 8d41251b4c30..e9d50e226b13 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -671,6 +671,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } E::Special::HotEnabled | E::Special::HotDisabled => { + if identifier_opts.is_delete_target() + || identifier_opts.assign_target() != js_ast::AssignTarget::None + { + return None; + } let enabled = p.options.features.hot_module_reloading; if name == b"data" { return Some(if enabled { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index b7750324fb8e..14ec25c1c058 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -155,10 +155,15 @@ describe("Bun.Transpiler", () => { ts.expectPrintedMin_('x = "foo"[2] = 5', 'x = "foo"[2] = 5'); ts.expectPrintedMin_("x = [y.z][0]()", "x = [y.z][0]()"); ts.expectPrintedMin_("x = [y][0]()", "x = [y][0]()"); - // A conditional operand is not a reference; when a fold hoists the - // live branch up to the delete, the printer wraps it. + // Comma / `?:` / `??` / `||` / `&&` produce a value, not a reference; + // when a fold hoists the live arm up to the delete, the printer + // re-wraps it using WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS. ts.expectPrinted_("x = delete (true ? a.b : 0)", "x = delete (0, a.b)"); ts.expectPrinted_("x = delete (true ? a : 0)", "x = delete (0, a)"); + ts.expectPrintedMin_("x = delete (0, a.b)", "x = delete (0, a.b)"); + ts.expectPrintedMin_("x = delete (null ?? a.b)", "x = delete (0, a.b)"); + ts.expectPrintedMin_("x = delete (0 || a.b)", "x = delete (0, a.b)"); + ts.expectPrintedMin_("x = delete (1 && a.b)", "x = delete (0, a.b)"); // Still inlined outside those positions. ts.expectPrintedMin_("x = [y][0]", "x = y"); ts.expectPrintedMin_('x = "foo"[2]', 'x = "o"'); @@ -192,6 +197,8 @@ describe("Bun.Transpiler", () => { ts.expectPrinted_("x = delete import.meta.main", "x = delete import.meta.main"); ts.expectPrinted_("import.meta.hot = 5", "import.meta.hot = 5"); ts.expectPrinted_("import.meta.main = 5", "import.meta.main = 5"); + ts.expectPrinted_("x = delete import.meta.hot.accept", "x = delete undefined.accept"); + ts.expectPrinted_("import.meta.hot.accept = fn", "undefined.accept = fn"); // Reads are still inlined. ts.expectPrinted_("x = import.meta.hot", "x = undefined"); }); @@ -205,12 +212,14 @@ describe("Bun.Transpiler", () => { var r2 = delete { f: k.f }.f; var o = { m() { return this === o } }; var r3 = [o.m][0](); - console.log(JSON.stringify([obj.p, r1, y, k.f, r2, r3])); + var q = { p: 1 }; + delete (0, q.p); + console.log(JSON.stringify([obj.p, r1, y, k.f, r2, r3, q.p])); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("[1,true,1,7,true,false]\n"); + expect(stdout).toBe("[1,true,1,7,true,false,1]\n"); expect(exitCode).toBe(0); }); it("bails out on optional-chain index into enum", () => { From d8337c4650f06191d2cc9c62a20035560e12ca2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:56:09 +0000 Subject: [PATCH 07/10] js_parser: only bail the import.meta.hot. arm when HMR is disabled Under HMR-enabled (Bake dev server), every rewrite in this arm produces a valid hmr. 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. --- src/js_parser/fold.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index e9d50e226b13..a21568d32bb0 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -671,12 +671,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } E::Special::HotEnabled | E::Special::HotDisabled => { - if identifier_opts.is_delete_target() - || identifier_opts.assign_target() != js_ast::AssignTarget::None + let enabled = p.options.features.hot_module_reloading; + // The !enabled rewrites below produce `undefined` / `{}`; + // keep the property reference under delete/assign so + // `delete import.meta.hot.accept` stays a reference + // instead of `delete undefined`. The enabled rewrites + // all produce `hmr.` references and must run so + // the printer doesn't fall back to `hmr.indirectHot`. + if !enabled + && (identifier_opts.is_delete_target() + || identifier_opts.assign_target() != js_ast::AssignTarget::None) { return None; } - let enabled = p.options.features.hot_module_reloading; if name == b"data" { return Some(if enabled { Expr { From ad2cb6d3a97e57a5b6b3c74f6cfaa811f17967ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:56:37 +0000 Subject: [PATCH 08/10] trim comment --- src/js_parser/fold.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index a21568d32bb0..33b56e141452 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -672,12 +672,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } E::Special::HotEnabled | E::Special::HotDisabled => { let enabled = p.options.features.hot_module_reloading; - // The !enabled rewrites below produce `undefined` / `{}`; - // keep the property reference under delete/assign so - // `delete import.meta.hot.accept` stays a reference - // instead of `delete undefined`. The enabled rewrites - // all produce `hmr.` references and must run so - // the printer doesn't fall back to `hmr.indirectHot`. + // !enabled rewrites produce values (undefined/{}), so keep the + // reference under delete/assign. enabled rewrites produce hmr. + // refs and must run (HotEnabled prints as throwing `hmr.indirectHot`). if !enabled && (identifier_opts.is_delete_target() || identifier_opts.assign_target() != js_ast::AssignTarget::None) From 60d13f7f3eb92219488623dfa6a2157179aab254 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:27 +0000 Subject: [PATCH 09/10] ci: retrigger From 470e769df861371f25c3466d12253b6410d40263 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:16:19 +0000 Subject: [PATCH 10/10] js_printer: wrap EUndefined in the delete (0, ...) re-wrap helper `--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. --- src/js_printer/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index ea6bc77a3ae1..d249f17ed3ce 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1216,6 +1216,9 @@ fn is_identifier_or_numeric_constant_or_property_access(expr: &js_ast::Expr) -> use js_ast::ExprData; match &expr.data { ExprData::EIdentifier(_) | ExprData::EDot(_) | ExprData::EIndex(_) => true, + // These print as bare identifiers (`undefined`/`Infinity`/`NaN`) which + // `delete` then parses back as a reference (strict-mode SyntaxError). + ExprData::EUndefined(_) => true, ExprData::ENumber(e) => e.value().is_infinite() || e.value().is_nan(), _ => false, }