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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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, } From b891365e2ba7945b5d4d7aada0dfee1d6fd20e61 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:14:47 +0000 Subject: [PATCH 11/21] js_parser: keep the identifier when assigning to a define with a constant value `X = 5` with `--define X='"abc"'` printed `"abc" = 5`. After deciding not to substitute a non-assignable define value into an assignment target, e_identifier still handed the define's value text to handle_identifier as a symbol name, so find_symbol allocated an unbound symbol literally named `"abc"` and rebound the identifier to it. Keep the original identifier instead, as esbuild does; reads are still replaced. --- src/js_parser/visit/visit_expr.rs | 9 +-- test/bundler/esbuild/default.test.ts | 91 +++++++++++++++------- test/bundler/transpiler/transpiler.test.js | 28 +++++++ 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 260a937e154b..f91283338e5f 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -255,8 +255,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.symbols[result.r#ref.inner_index() as usize].set_has_been_assigned_to(true); } - let mut original_name: Option<&[u8]> = None; - // Substitute user-specified defines for unbound symbols if p.symbols[e_.ref_.inner_index() as usize].kind == js_ast::symbol::Kind::Unbound && !result.is_inside_with_scope @@ -272,7 +270,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.value_for_define(expr.loc, in_.assign_target, is_delete_target, def); // Don't substitute an identifier for a non-identifier if this is an - // assignment target, since it'll cause a syntax error + // assignment target, since it'll cause a syntax error. The original + // identifier is kept in that case. if matches!(newvalue.data.tag(), Tag::EIdentifier) || in_.assign_target == js_ast::AssignTarget::None { @@ -280,8 +279,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O *e = newvalue; return; } - - original_name = def.original_name(); } // Copy the side effect flags over in case this expression is unused @@ -326,7 +323,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O *e = p.handle_identifier( expr.loc, e_, - original_name, + None, IdentifierOpts::default() .with_assign_target(in_.assign_target) .with_is_delete_target(is_delete_target) diff --git a/test/bundler/esbuild/default.test.ts b/test/bundler/esbuild/default.test.ts index 950319df7bab..8860c329dc13 100644 --- a/test/bundler/esbuild/default.test.ts +++ b/test/bundler/esbuild/default.test.ts @@ -4625,35 +4625,68 @@ describe.concurrent("bundler", () => { stdout: "1\n2", }, }); - // TODO: this doesnt warn in esbuild ??? - // itBundled("default/DefineAssignWarning", { - // // GENERATED - // files: { - // "/read.js": /* js */ ` - // console.log( - // [a, b.c, b['c']], - // [d, e.f, e['f']], - // [g, h.i, h['i']], - // ) - // `, - // "/write.js": /* js */ ` - // console.log( - // [a = 0, b.c = 0, b['c'] = 0], - // [d = 0, e.f = 0, e['f'] = 0], - // [g = 0, h.i = 0, h['i'] = 0], - // ) - // `, - // }, - // entryPoints: ["/read.js", "/write.js"], - // define: { - // a: "null", - // "b.c": "null", - // d: "ident", - // "e.f": "ident", - // g: "dot.chain", - // "h.i": "dot.chain", - // }, - // }); + // A define with a constant value (`a`) is only substituted where it is read; + // as an assignment target the identifier stays as written, since `null = 0` + // is a syntax error. esbuild also logs an "assign-to-define" warning there, + // bun does not. A define whose value is an identifier (`d`) is substituted + // in both positions. + itBundled("default/DefineAssignWarning", { + files: { + "/read.js": /* js */ ` + capture(a); + capture(d); + `, + "/write.js": /* js */ ` + capture(a = 1); + capture(a += 1); + capture(a++); + capture(--a); + capture([a] = [3]); + capture({ x: a } = { x: 4 }); + capture(a ??= 5); + for (a in { 5: 0 }); + for (a of [6]); + capture(d = 7); + console.log(a, globalThis.a, globalThis.ident); + `, + }, + entryPoints: ["/read.js", "/write.js"], + define: { + a: "null", + d: "ident", + }, + runtimeFiles: { + // The output is an ES module, so the globals it assigns to must exist. + "/test.js": /* js */ ` + globalThis.capture = x => x; + globalThis.a = undefined; + globalThis.ident = undefined; + await import("./out/write.js"); + `, + }, + onAfterBundle(api) { + expect(api.captureFile("/out/read.js")).toEqual(["null", "ident"]); + expect(api.captureFile("/out/write.js")).toEqual([ + "a = 1", + "a += 1", + "a++", + "--a", + "[a] = [3]", + "{ x: a } = { x: 4 }", + "a ??= 5", + "ident = 7", + ]); + const write = api.readFile("/out/write.js"); + expect(write).toContain("for (a in { 5: 0 })"); + expect(write).toContain("for (a of [6])"); + }, + run: { + file: "/test.js", + // The read of `a` is replaced with the define, the writes went to the + // real global. + stdout: "null 6 7", + }, + }); itBundled("default/KeepNamesTreeShaking", { todo: true, // TODO: Full keepNames implementation with Object.defineProperty files: { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index a9a0d382aaed..12dee4e347ff 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3052,6 +3052,34 @@ console.log(resolve.length) expectPrinted_("Math.log('hi')", 'console.error("hi")'); }); + it("define with a constant value keeps the identifier in an assignment target", () => { + // Only reads of `user_undefined` are replaced; `undefined = 1` would + // silently assign to the wrong name. + expectPrinted_( + `user_undefined = 1; console.log(user_undefined);`, + `user_undefined = 1;\nconsole.log(undefined);\n`, + ); + expectPrinted_(`user_undefined += 1`, `user_undefined += 1`); + expectPrinted_(`user_undefined++`, `user_undefined++`); + expectPrinted_(`[user_undefined] = [1]`, `[user_undefined] = [1]`); + expectPrinted_(`({ a: user_undefined } = { a: 1 })`, `({ a: user_undefined } = { a: 1 })`); + expectPrinted_(`for (user_undefined of []);`, `for (user_undefined of [])\n ;\n`); + expectPrintedMin_( + `user_undefined = 1; console.log(user_undefined);`, + `user_undefined = 1;\nconsole.log(void 0);\n`, + ); + + // A define whose value is itself assignable is substituted either way. + expectPrinted_(`user_nested = 1`, `location.origin = 1`); + + // Non-identifier values used to be looked up as if they were a symbol + // name, printing the value's source text as the assignment target. + const constants = new Bun.Transpiler({ define: { STR: JSON.stringify("abc"), NUM: "1" } }); + expect(constants.transformSync(`STR = 1; NUM = 1; console.log(STR, NUM);`, "js")).toBe( + `STR = 1;\nNUM = 1;\nconsole.log("abc", 1);\n`, + ); + }); + it("jsx symbol should work", () => { expectBunPrinted_(`var x = jsx; export default x;`, "var x = jsx;\nexport default x"); }); From 1031207ebd114478d71c9a0ed8512839a6f54468 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:13:20 +0000 Subject: [PATCH 12/21] Bump the runtime transpiler cache version; cover object define values `bun test` keeps the runtime transpiler cache enabled when user defines are set, so entries transpiled before this fix can hold the invalid assignment output. Bumping the version makes them miss. --- src/jsc/RuntimeTranspilerCache.rs | 6 +++++- test/bundler/transpiler/transpiler.test.js | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 3373c1402e99..76b3e185c75c 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,7 +51,11 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -const EXPECTED_VERSION: u32 = 25; +/// Version 26: assigning to an identifier whose define has a non-identifier +/// value keeps the identifier instead of printing the value (#21210). `bun test` +/// caches output transpiled with user defines, so older entries may hold the +/// invalid output. +const EXPECTED_VERSION: u32 = 26; /// 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/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 12dee4e347ff..881d7c3b4168 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3074,9 +3074,13 @@ console.log(resolve.length) // Non-identifier values used to be looked up as if they were a symbol // name, printing the value's source text as the assignment target. - const constants = new Bun.Transpiler({ define: { STR: JSON.stringify("abc"), NUM: "1" } }); - expect(constants.transformSync(`STR = 1; NUM = 1; console.log(STR, NUM);`, "js")).toBe( - `STR = 1;\nNUM = 1;\nconsole.log("abc", 1);\n`, + // Object values are inlined at every read (esbuild hoists them into a + // variable instead), so in an assignment target they count as constants too. + const constants = new Bun.Transpiler({ + define: { STR: JSON.stringify("abc"), NUM: "1", OBJ: JSON.stringify({ a: 1 }) }, + }); + expect(constants.transformSync(`STR = 1; NUM = 1; OBJ = 1; console.log(STR, NUM, OBJ);`, "js")).toBe( + `STR = 1;\nNUM = 1;\nOBJ = 1;\nconsole.log("abc", 1, { a: 1 });\n`, ); }); From 605d93f083da8b4cf96189b5fafa5d9decb302a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:18:42 +0000 Subject: [PATCH 13/21] Trim comments --- src/js_parser/visit/visit_expr.rs | 3 +-- src/jsc/RuntimeTranspilerCache.rs | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index f91283338e5f..17a6a3c6c1fa 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -270,8 +270,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.value_for_define(expr.loc, in_.assign_target, is_delete_target, def); // Don't substitute an identifier for a non-identifier if this is an - // assignment target, since it'll cause a syntax error. The original - // identifier is kept in that case. + // assignment target, since it'll cause a syntax error if matches!(newvalue.data.tag(), Tag::EIdentifier) || in_.assign_target == js_ast::AssignTarget::None { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 76b3e185c75c..3edb34dd6147 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,10 +51,8 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -/// Version 26: assigning to an identifier whose define has a non-identifier -/// value keeps the identifier instead of printing the value (#21210). `bun test` -/// caches output transpiled with user defines, so older entries may hold the -/// invalid output. +/// Version 26: Assigning to a define with a non-identifier value keeps the +/// identifier instead of printing the value (#21210). const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk From d7d1c5bec7b9eff1deb0b81d245e1d6832ab02e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:22:15 +0000 Subject: [PATCH 14/21] Shorten the cache version note --- src/jsc/RuntimeTranspilerCache.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 3edb34dd6147..ebfac51f54e3 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -51,8 +51,7 @@ bun_core::declare_scope!(cache, visible); /// Version 25: Every ModuleInfo record carries a trailing FetchParameters slot /// so ImportEntry/ExportEntry/StarExportEntry moduleRequestType matches JSC's /// after WebKit 90b2ecf79ae3 keyed m_loadedModules on (specifier, type). -/// Version 26: Assigning to a define with a non-identifier value keeps the -/// identifier instead of printing the value (#21210). +/// Version 26: Assigning to a define with a constant value keeps the identifier (#21210). const EXPECTED_VERSION: u32 = 26; /// Source files smaller than this are not written to / read from the on-disk From f4b3dcb8b6e5eece02ef42c9acb66d6584ba7ef5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:46:42 +0000 Subject: [PATCH 15/21] ci: retrigger From 4138ff1ee22a92c6b88ff36b2f31dbf25373a2f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:45:25 +0000 Subject: [PATCH 16/21] js_parser: keep "str".length as an assignment target under minify-syntax The EString arm of maybe_rewrite_property_access folded "foo".length to a number with only a minify_syntax check, so `"foo".length = 4` printed `3 = 4`, which is an early SyntaxError. The runtime transpiler runs with this fold on, so `bun file.js` failed to load a valid program. Skip the fold when the access is an assignment target, as esbuild does. --- src/js_parser/fold.rs | 5 +- .../assign-target-substitution.test.ts | 204 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 test/bundler/transpiler/assign-target-substitution.test.ts diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 74125b583e17..6b6f8d2acb86 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 { + // `"foo".length = x` must stay a property write, not `3 = x`. + if p.options.features.minify_syntax + && 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/assign-target-substitution.test.ts b/test/bundler/transpiler/assign-target-substitution.test.ts new file mode 100644 index 000000000000..f37b2574a20b --- /dev/null +++ b/test/bundler/transpiler/assign-target-substitution.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// A constant must never replace an assignment target. `"foo".length = 4` is a +// run-time TypeError (strict) or a no-op (sloppy), but `3 = 4` is an early +// SyntaxError. The same holds for `--define X=0` turning `X = 1` into `0 = 1`. +// esbuild leaves every case below untouched; so does bun now. + +type Case = [name: string, source: string, printed: string]; + +// The printed output is the source itself unless the row says otherwise. +function unchanged(rows: [name: string, source: string, printed?: string][]): Case[] { + return rows.map(([name, source, printed]) => [name, source, printed ?? source]); +} + +const stringLengthTargets = unchanged([ + ["assignment", `"foo".length = 4;`], + ["compound assignment", `"foo".length += 1;`], + ["postfix increment", `"foo".length++;`], + ["prefix increment", `++"foo".length;`], + ["postfix decrement", `"foo".length--;`], + ["prefix decrement", `--"foo".length;`], + ["for-of head", `for ("ab".length of [1]);`, `for ("ab".length of [1])\n ;`], + ["for-in head", `for ("ab".length in { a: 1 });`, `for ("ab".length in { a: 1 })\n ;`], + ["array destructuring", `["ab".length] = [1];`], + ["nested array destructuring", `[["ab".length]] = [[1]];`], + ["array destructuring with default", `["ab".length = 5] = [];`], + ["object destructuring", `({ x: "ab".length } = {});`], + ["nested object destructuring", `({ x: { y: "ab".length } } = { x: {} });`], + ["object destructuring with default", `({ x: "ab".length = 5 } = {});`], + ["bracket access", `"foo"["length"] = 4;`, `"foo".length = 4;`], +]); + +// Reads of `"str".length` still fold. +const stringLengthReads: Case[] = [ + ["assigned value", `y = "foo".length;`, `y = 3;`], + ["destructuring default value", `[y = "ab".length] = [];`, `[y = 2] = [];`], + ["for-of iterable", `for (y of "ab".length);`, `for (y of 2)\n ;`], +]; + +const defineTargets = unchanged([ + ["assignment", `X = 1;`], + ["compound assignment", `X += 1;`], + ["postfix increment", `X++;`], + ["prefix increment", `++X;`], + ["postfix decrement", `X--;`], + ["prefix decrement", `--X;`], + ["for-of head", `for (X of y);`, `for (X of y)\n ;`], + ["for-in head", `for (X in y);`, `for (X in y)\n ;`], + ["array destructuring", `[X] = [1];`], + ["nested array destructuring", `[[X]] = [[1]];`], + ["array destructuring with default", `[X = 2] = [];`], + ["array rest", `[...X] = [1];`], + ["object destructuring", `({ x: X } = {});`], + ["object shorthand", `({ X } = {});`], + ["object shorthand with default", `({ X = 2 } = {});`], + ["nested object destructuring", `({ x: { y: X } } = { x: {} });`], + ["object destructuring with default", `({ x: X = 2 } = {});`], + ["object rest", `({ ...X } = {});`], +]); + +// Reads of `X` are still replaced by the define. +const defineReads: Case[] = [ + ["argument", `console.log(X);`, `console.log(0);`], + ["destructuring default value", `[y = X] = [];`, `[y = 0] = [];`], + ["object destructuring default value", `({ x: y = X } = {});`, `({ x: y = 0 } = {});`], + ["for-of iterable", `for (y of X);`, `for (y of 0)\n ;`], +]; + +// An identifier is a valid target, so `--define X=FOO` still applies there. +const defineIdentifierTargets: Case[] = [ + ["assignment", `X = 1;`, `FOO = 1;`], + ["compound assignment", `X += 1;`, `FOO += 1;`], + ["postfix increment", `X++;`, `FOO++;`], + ["for-of head", `for (X of y);`, `for (FOO of y)\n ;`], + ["array destructuring", `[X] = [1];`, `[FOO] = [1];`], + ["object destructuring", `({ x: X } = {});`, `({ x: FOO } = {});`], + ["object shorthand", `({ X } = {});`, `({ X: FOO } = {});`], +]; + +describe("Bun.Transpiler", () => { + const minify = new Bun.Transpiler({ minify: { syntax: true } }); + const define = new Bun.Transpiler({ define: { X: "0" } }); + const defineIdentifier = new Bun.Transpiler({ define: { X: "FOO" } }); + + // trim() on both ends: the printer puts a space before a prefix `++` or `--` + // that starts the output. + const transform = (transpiler: Bun.Transpiler, source: string) => transpiler.transformSync(source, "js").trim(); + + test.each(stringLengthTargets)(`keeps "str".length as an assignment target: %s`, (_name, source, printed) => { + expect(transform(minify, source)).toBe(printed); + }); + + test.each(stringLengthReads)(`folds "str".length when read: %s`, (_name, source, printed) => { + expect(transform(minify, source)).toBe(printed); + }); + + test.each(defineTargets)("keeps a defined identifier as an assignment target: %s", (_name, source, printed) => { + expect(transform(define, source)).toBe(printed); + }); + + test.each(defineReads)("substitutes a defined identifier when read: %s", (_name, source, printed) => { + expect(transform(define, source)).toBe(printed); + }); + + test.each(defineIdentifierTargets)( + "substitutes an identifier define in an assignment target: %s", + (_name, source, printed) => { + expect(transform(defineIdentifier, source)).toBe(printed); + }, + ); +}); + +const join = (cases: Case[]) => ({ + source: cases.map(c => c[1]).join("\n") + "\n", + printed: cases.map(c => c[2]).join("\n") + "\n", +}); + +async function run(cmd: string[], cwd: string) { + await using proc = Bun.spawn({ + cmd, + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("bun build", () => { + test.concurrent("--minify-syntax keeps every assignment target", async () => { + const { source, printed } = join([...stringLengthTargets, ...stringLengthReads]); + using dir = tempDir("length-target-build", { "entry.js": source }); + + const { stdout, stderr, exitCode } = await run( + [bunExe(), "build", "--no-bundle", "--minify-syntax", "entry.js"], + String(dir), + ); + + expect(stderr).toBe(""); + expect(stdout).toBe(printed); + expect(exitCode).toBe(0); + }); + + test.concurrent.each([["--no-bundle"], ["bundled"]])("--define keeps every assignment target (%s)", async mode => { + const { source, printed } = join([...defineTargets, ...defineReads]); + using dir = tempDir("define-target-build", { "entry.js": source }); + + const { stdout, stderr, exitCode } = await run( + [bunExe(), "build", ...(mode === "--no-bundle" ? ["--no-bundle"] : []), "--define", "X=0", "entry.js"], + String(dir), + ); + + expect(stderr).toBe(""); + expect(stdout).toBe(mode === "--no-bundle" ? printed : `// entry.js\n${printed}`); + expect(exitCode).toBe(0); + }); +}); + +describe("bun run", () => { + // The runtime transpiler folds `"str".length`. The module must load; each + // write is a run-time TypeError in strict mode, as in node. + test.concurrent(`"str".length as an assignment target is a run-time TypeError`, async () => { + const writes = stringLengthTargets.map(c => c[1]); + using dir = tempDir("length-target-run", { + "entry.mjs": ` + const results = []; + for (const write of [${writes.map(w => `() => { ${w} }`).join(", ")}]) { + try { write(); results.push("ok"); } catch (e) { results.push(e.constructor.name); } + } + console.log(results.join(",")); + console.log("foo".length); + `, + }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "entry.mjs"], String(dir)); + + expect(stderr).toBe(""); + expect(stdout).toBe(`${writes.map(() => "TypeError").join(",")}\n3\n`); + expect(exitCode).toBe(0); + }); + + // In sloppy mode the writes create a real global. Reads still see the define. + test.concurrent("--define keeps writes to the defined identifier", async () => { + using dir = tempDir("define-target-run", { + "entry.cjs": ` + X = 1; + X += 1; + X++; + for (X of [5]); + [X] = [6]; + ({ x: X } = { x: 7 }); + console.log(X, globalThis.X); + `, + }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "--define", "X=0", "entry.cjs"], String(dir)); + + expect(stderr).toBe(""); + expect(stdout).toBe("0 7\n"); + expect(exitCode).toBe(0); + }); +}); From 538c155d1c681019d79197779942a8247495f386 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:28:30 +0000 Subject: [PATCH 17/21] Cover import.meta writes under --format=cjs --- .../assign-target-substitution.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/bundler/transpiler/assign-target-substitution.test.ts b/test/bundler/transpiler/assign-target-substitution.test.ts index f37b2574a20b..90d21bbb63b6 100644 --- a/test/bundler/transpiler/assign-target-substitution.test.ts +++ b/test/bundler/transpiler/assign-target-substitution.test.ts @@ -156,6 +156,28 @@ describe("bun build", () => { expect(stdout).toBe(mode === "--no-bundle" ? printed : `// entry.js\n${printed}`); expect(exitCode).toBe(0); }); + + // With `--format=cjs`, a read of `import.meta.main`, `.dir` or `.url` inlines + // to a constant. A write keeps the property reference. + test.concurrent("--format=cjs keeps import.meta as an assignment target", async () => { + const { source, printed } = join( + unchanged([ + ["assignment", `import.meta.main = 1;`], + ["postfix increment", `import.meta.main++;`], + ["array destructuring", `[import.meta.main] = [1];`], + ["dir", `import.meta.dir = 1;`], + ["url", `import.meta.url = 1;`], + ["read", `console.log(import.meta.main);`, `console.log(require.main == module);`], + ]), + ); + using dir = tempDir("import-meta-target-build", { "entry.js": source }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "build", "--format=cjs", "entry.js"], String(dir)); + + expect(stderr).toBe(""); + expect(stdout).toBe(`// entry.js\n${printed}`); + expect(exitCode).toBe(0); + }); }); describe("bun run", () => { From 0ed4ad41fe13fbdf8c4a580486eae1c88f3e48b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:30:35 +0000 Subject: [PATCH 18/21] Trim comments --- src/js_parser/fold.rs | 6 +----- src/js_parser/visit/visit_expr.rs | 4 +--- src/jsc/RuntimeTranspilerCache.rs | 4 +--- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index 8e4aa8ad022f..dbd6aa6d4362 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -443,8 +443,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } js_ast::ExprData::EString(str_) => { - // A write or delete keeps the property reference: `3 = x` is a - // syntax error and `delete 3` is `true`, not `false`. if p.options.features.minify_syntax && !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None @@ -674,9 +672,7 @@ 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; - // !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`). + // Only the disabled rewrites produce values; the enabled ones are `hmr.` references. if !enabled && (identifier_opts.is_delete_target() || identifier_opts.assign_target() != js_ast::AssignTarget::None) diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 0aceb4e99d2b..5dba6949d8b3 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -1047,9 +1047,7 @@ 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(); - // Folding a property reference to a value is unsafe where the - // reference itself is observed (delete result, assign target; the call - // receiver case is handled inside via `(0, x)`). + // `[x][0] = v` and `delete [x][0]` act on the temporary, not on `x`. if p.options.features.minify_syntax && !is_delete_target && in_.assign_target == js_ast::AssignTarget::None diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 23efda08f470..9bd80db413fa 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -55,9 +55,7 @@ bun_core::declare_scope!(cache, visible); /// offsets picked by a header byte) plus a body of tagged records with /// u8/u16/u32 ids and implied slots dropped, instead of fixed u32 arrays. /// Version 27: ModuleInfo string table holds Latin-1 / UTF-16 bodies, not WTF-8. -/// Version 28: `delete`, assign and call targets no longer fold under -/// minify_syntax, and a define with a constant value keeps the identifier in an -/// assignment target (#21210). Older entries can hold the invalid output (`0 = 1`). +/// Version 28: delete and assignment targets are no longer folded to a value (#21210). const EXPECTED_VERSION: u32 = 28; /// Source files smaller than this are not written to / read from the on-disk From 84ae516f8d6c3436f1385d24c450aa111e811b35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:49:24 +0000 Subject: [PATCH 19/21] Cover logical assignment operators in the assignment target matrix `X ??= 1` with --define X=0 printed `0 ??= 1` and `"foo".length ??= 1` printed `3 ??= 1` under --minify-syntax. Both go through the same assign_target guard as `X += 1`. Add `??=`, `||=` and `&&=` rows so the matrix covers them on every entry point. --- .../transpiler/assign-target-substitution.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/bundler/transpiler/assign-target-substitution.test.ts b/test/bundler/transpiler/assign-target-substitution.test.ts index 90d21bbb63b6..404aad94063c 100644 --- a/test/bundler/transpiler/assign-target-substitution.test.ts +++ b/test/bundler/transpiler/assign-target-substitution.test.ts @@ -16,6 +16,9 @@ function unchanged(rows: [name: string, source: string, printed?: string][]): Ca const stringLengthTargets = unchanged([ ["assignment", `"foo".length = 4;`], ["compound assignment", `"foo".length += 1;`], + ["nullish assignment", `"foo".length ??= 1;`], + ["logical or assignment", `"foo".length ||= 1;`], + ["logical and assignment", `"foo".length &&= 1;`], ["postfix increment", `"foo".length++;`], ["prefix increment", `++"foo".length;`], ["postfix decrement", `"foo".length--;`], @@ -41,6 +44,9 @@ const stringLengthReads: Case[] = [ const defineTargets = unchanged([ ["assignment", `X = 1;`], ["compound assignment", `X += 1;`], + ["nullish assignment", `X ??= 1;`], + ["logical or assignment", `X ||= 1;`], + ["logical and assignment", `X &&= 1;`], ["postfix increment", `X++;`], ["prefix increment", `++X;`], ["postfix decrement", `X--;`], @@ -71,6 +77,7 @@ const defineReads: Case[] = [ const defineIdentifierTargets: Case[] = [ ["assignment", `X = 1;`, `FOO = 1;`], ["compound assignment", `X += 1;`, `FOO += 1;`], + ["nullish assignment", `X ??= 1;`, `FOO ??= 1;`], ["postfix increment", `X++;`, `FOO++;`], ["for-of head", `for (X of y);`, `for (FOO of y)\n ;`], ["array destructuring", `[X] = [1];`, `[FOO] = [1];`], @@ -182,9 +189,12 @@ describe("bun build", () => { describe("bun run", () => { // The runtime transpiler folds `"str".length`. The module must load; each - // write is a run-time TypeError in strict mode, as in node. + // write is a run-time TypeError in strict mode, as in node. `??=` and `||=` + // never write because `"foo".length` is 3. test.concurrent(`"str".length as an assignment target is a run-time TypeError`, async () => { + const shortCircuits = new Set(["nullish assignment", "logical or assignment"]); const writes = stringLengthTargets.map(c => c[1]); + const expected = stringLengthTargets.map(([name]) => (shortCircuits.has(name) ? "ok" : "TypeError")); using dir = tempDir("length-target-run", { "entry.mjs": ` const results = []; @@ -199,7 +209,7 @@ describe("bun run", () => { const { stdout, stderr, exitCode } = await run([bunExe(), "entry.mjs"], String(dir)); expect(stderr).toBe(""); - expect(stdout).toBe(`${writes.map(() => "TypeError").join(",")}\n3\n`); + expect(stdout).toBe(`${expected.join(",")}\n3\n`); expect(exitCode).toBe(0); }); From 73340c7171db40fafb8d0a2c9cd71953126ea336 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:53:16 +0000 Subject: [PATCH 20/21] Keep module.id, module.filename and module.path as delete targets The three arms in maybe_rewrite_property_access checked assign_target only, so `delete module.id` bundled to `delete "lib.cjs"`. Add the is_delete_target guard that the neighbouring module.exports arm uses. Test the import.meta writes on ESM output from a non-entry module, where `import.meta.main` reads inline to `false`, instead of on --format=cjs output, where import.meta itself is not valid. --- src/js_parser/fold.rs | 3 ++ .../assign-target-substitution.test.ts | 54 +++++++++++++++---- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/js_parser/fold.rs b/src/js_parser/fold.rs index dbd6aa6d4362..e38d7b909414 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -349,6 +349,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }); } else if p.options.bundle && name == b"id" + && !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None { // inline module.id @@ -356,6 +357,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return Some(p.new_expr(e_string_init(p.source.path.pretty), name_loc)); } else if p.options.bundle && name == b"filename" + && !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None { // inline module.filename @@ -365,6 +367,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O ); } else if p.options.bundle && name == b"path" + && !identifier_opts.is_delete_target() && identifier_opts.assign_target() == js_ast::AssignTarget::None { // inline module.path diff --git a/test/bundler/transpiler/assign-target-substitution.test.ts b/test/bundler/transpiler/assign-target-substitution.test.ts index 404aad94063c..ee2f6c84fb7b 100644 --- a/test/bundler/transpiler/assign-target-substitution.test.ts +++ b/test/bundler/transpiler/assign-target-substitution.test.ts @@ -164,25 +164,59 @@ describe("bun build", () => { expect(exitCode).toBe(0); }); - // With `--format=cjs`, a read of `import.meta.main`, `.dir` or `.url` inlines - // to a constant. A write keeps the property reference. - test.concurrent("--format=cjs keeps import.meta as an assignment target", async () => { - const { source, printed } = join( + // In a bundle, a read of `import.meta.main` in a non-entry module inlines to + // `false` and a read of `import.meta.hot` inlines to `undefined`. A write or + // delete keeps the property reference. + test.concurrent("keeps import.meta as an assignment or delete target in a bundle", async () => { + const lib = join( unchanged([ ["assignment", `import.meta.main = 1;`], ["postfix increment", `import.meta.main++;`], ["array destructuring", `[import.meta.main] = [1];`], - ["dir", `import.meta.dir = 1;`], - ["url", `import.meta.url = 1;`], - ["read", `console.log(import.meta.main);`, `console.log(require.main == module);`], + ["delete", `delete import.meta.main;`], + ["hot", `import.meta.hot = 1;`], + ["read", `console.log(import.meta.main, import.meta.hot);`, `console.log(false, undefined);`], ]), ); - using dir = tempDir("import-meta-target-build", { "entry.js": source }); + using dir = tempDir("import-meta-target-build", { + "entry.js": `import "./lib.js";\nimport.meta.hot = 2;\nconsole.log(import.meta.hot);\n`, + "lib.js": lib.source, + }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "build", "entry.js"], String(dir)); + + expect(stderr).toBe(""); + expect(stdout).toBe(`// lib.js\n${lib.printed}\n// entry.js\nimport.meta.hot = 2;\nconsole.log(undefined);\n`); + expect(exitCode).toBe(0); + }); - const { stdout, stderr, exitCode } = await run([bunExe(), "build", "--format=cjs", "entry.js"], String(dir)); + // In a bundle, a read of `module.id`, `module.filename` or `module.path` + // inlines to a string. A delete keeps the property reference. + test.concurrent("keeps module.id, module.filename and module.path as delete targets in a bundle", async () => { + using dir = tempDir("module-id-target-build", { + "entry.js": `import "./lib.cjs";\n`, + "lib.cjs": ` + delete module.id; + delete module.filename; + delete module.path; + module.id = 1; + console.log(module.id, module.filename, module.path); + `, + }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "build", "entry.js"], String(dir)); expect(stderr).toBe(""); - expect(stdout).toBe(`// entry.js\n${printed}`); + // The module body is printed inside the CommonJS wrapper. + expect(stdout).toContain( + [ + ` delete module.id;`, + ` delete module.filename;`, + ` delete module.path;`, + ` module.id = 1;`, + ` console.log("lib.cjs", "lib.cjs", "lib.cjs");`, + ].join("\n"), + ); expect(exitCode).toBe(0); }); }); From 55fbf03c4b6cce536120bcd11e62a73e7d367714 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:23:03 +0000 Subject: [PATCH 21/21] Cover import.meta.dir, .file, .path and .url writes under --format=cjs --- .../assign-target-substitution.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/bundler/transpiler/assign-target-substitution.test.ts b/test/bundler/transpiler/assign-target-substitution.test.ts index ee2f6c84fb7b..e0c2d96012b3 100644 --- a/test/bundler/transpiler/assign-target-substitution.test.ts +++ b/test/bundler/transpiler/assign-target-substitution.test.ts @@ -190,6 +190,28 @@ describe("bun build", () => { expect(exitCode).toBe(0); }); + // With `--format=cjs`, a read of `import.meta.dir`, `.file`, `.path` or `.url` + // inlines to a string. A write keeps the property reference. (`import.meta` + // is not valid in a CommonJS script either way; this pins the guard only.) + test.concurrent("--format=cjs keeps import.meta.dir, .file, .path and .url as assignment targets", async () => { + const { source, printed } = join( + unchanged([ + ["dir", `import.meta.dir = 1;`], + ["file", `import.meta.file = 1;`], + ["path", `import.meta.path = 1;`], + ["url", `import.meta.url = 1;`], + ["read", `console.log(import.meta.file);`, `console.log("entry.js");`], + ]), + ); + using dir = tempDir("import-meta-cjs-target-build", { "entry.js": source }); + + const { stdout, stderr, exitCode } = await run([bunExe(), "build", "--format=cjs", "entry.js"], String(dir)); + + expect(stderr).toBe(""); + expect(stdout).toBe(`// entry.js\n${printed}`); + expect(exitCode).toBe(0); + }); + // In a bundle, a read of `module.id`, `module.filename` or `module.path` // inlines to a string. A delete keeps the property reference. test.concurrent("keeps module.id, module.filename and module.path as delete targets in a bundle", async () => {