From a5406367adf96e36e1eafa597b3d71e81a0cbacc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:06:35 +0000 Subject: [PATCH 1/4] js_parser: fix exports.eliminate/replace on an exported declaration with no initializer The arm of visit_decls for a declaration with no initializer read the result of replace_decl_and_possibly_remove the wrong way round. An eliminated export went to visit_decl, which unwraps the missing value and aborts the process. A replaced export was dropped from the module. The arm now does what the arm for a declaration with an initializer does: drop the declaration when the helper returns false, visit it otherwise. A namespace member with no value is left alone, so that it prints what it prints today. --- src/js_parser/visit/mod.rs | 22 +++--- test/bundler/transpiler/transpiler.test.js | 84 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index b6466e4c2b70..304564f96209 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -598,7 +598,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, }, ); - } else if IS_POSSIBLY_DECL_TO_REMOVE { + } else if IS_POSSIBLY_DECL_TO_REMOVE && self.enclosing_namespace_arg_ref.is_none() { + // A namespace member with no value prints nothing, and a value would print as `NS.x = value`. if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); @@ -613,18 +614,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // which is not mutated during the visit pass. let replacer = _ptr.get(); if !self.replace_decl_and_possibly_remove(decl, replacer) { - let is_after = self.vis_scope().is_after_const_local_prefix; - self.visit_decl( - decl, - VisitDeclOpts { - was_anonymous_named_expr: false, - could_be_const_value: was_const && !is_after, - could_be_macro: false, - }, - ); - } else { continue 'outer; } + let is_after = self.vis_scope().is_after_const_local_prefix; + self.visit_decl( + decl, + VisitDeclOpts { + was_anonymous_named_expr: false, + could_be_const_value: was_const && !is_after, + could_be_macro: false, + }, + ); } } } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 5eca13297c30..629631e49226 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2345,6 +2345,90 @@ export default class { `); }); + describe("an export with no initializer", () => { + const eliminate = (names, options) => ({ exports: { eliminate: names }, ...options }); + const replace = (entries, options) => ({ exports: { replace: entries }, ...options }); + const printed = stdout => ({ stdout, stderr: "", exitCode: 0, signalCode: null }); + const expected = rows => rows.map(([, , module, names]) => [module, module, names]); + const print = async rows => { + const modules = []; + for (const [options, source] of rows) { + const transpiler = new Bun.Transpiler({ loader: "ts", ...options }); + const module = transpiler.transformSync(source); + modules.push([module, await transpiler.transform(source), transpiler.scan(source).exports]); + } + return modules; + }; + const ns = `var NS;\n((NS) => {})(NS ||= {});\n`; + + // `eliminate` aborted the process on these inputs, so they run in a subprocess. + it("eliminate", async () => { + // [options, source, printed module, exports that scan() reports] + const rows = [ + [eliminate(["A"]), `export let A`, ``, []], + [eliminate(["A"]), `export var A: number`, ``, []], + [eliminate(["A"], { loader: "js" }), `export let A; A = 1;`, `A = 1;\n`, []], + [eliminate(["A"]), `export let A, B = 1`, `export let B = 1;\n`, ["B"]], + [eliminate(["A", "B"], { treeShaking: false }), `export let A, B`, ``, []], + [eliminate(["A"], { deadCodeElimination: false }), `export let A`, ``, []], + [eliminate(["A"]), `namespace NS { export let A }`, ns, []], + [eliminate(["A"]), `namespace NS { export declare const A: number }`, ns, []], + ]; + const result = await bunRun(["-e", `console.log(JSON.stringify(await (${print})(${JSON.stringify(rows)})))`]); + expect(result).toEqual(printed(JSON.stringify(expected(rows)))); + }); + + it("replace", async () => { + const rows = [ + [replace({ A: 1 }), `export let A`, `export let A = 1;\n`, ["A"]], + [replace({ A: "bar" }), `export let A`, `export let A = "bar";\n`, ["A"]], + [replace({ A: "bar" }, { loader: "js" }), `export var A`, `export var A = "bar";\n`, ["A"]], + [replace({ A: ["N", "bar"] }), `export let A`, `export let N = "bar";\n`, ["N"]], + [replace({ A: 1 }), `export let A, B`, `export let A = 1;\nexport let B;\n`, ["A", "B"]], + [ + replace({ B: 1 }, { treeShaking: false }), + `export let A, B, C`, + `export let A, B = 1, C;\n`, + ["A", "B", "C"], + ], + // The write of the module comes after the replacement. + [replace({ A: 1 }), `export let A; A = 2;`, `export let A = 1;\nA = 2;\n`, ["A"]], + // As with an initializer, the replaced export ends the prefix of constants to inline. + [ + replace({ A: 1 }, { inline: true }), + `export let A; const B = 2; f(B)`, + `export let A = 1;\nconst B = 2;\nf(B);\n`, + ["A"], + ], + // A namespace member with no value prints what it prints with no `exports` option. + [replace({ A: 1 }), `namespace NS { export let A }`, ns, []], + [replace({ A: ["N", 1] }), `namespace NS { export declare const A: number }`, ns, []], + [ + replace({ A: ["N", 1] }), + `namespace NS { let N; export let A }`, + `var NS;\n((NS) => {\n let N;\n})(NS ||= {});\n`, + [], + ], + ]; + expect(await print(rows)).toEqual(expected(rows)); + }); + + it("replace with a string, after the load of another module", async () => { + using dir = tempDir("exports-replace-no-initializer", { + "other.cjs": Array.from({ length: 50 }, (_, i) => `exports.v${i} = () => ["v", ${i}].join("-");`).join("\n"), + }); + const result = await bunRun([ + "-e", + `const transpiler = new Bun.Transpiler({ loader: "ts", exports: { replace: { A: "bar", B: 1 } } }); + require(${JSON.stringify(join(String(dir), "other.cjs"))}); + const sources = ["export let A", "export let A = 5", "export let B"]; + console.log(JSON.stringify(sources.map(source => transpiler.transformSync(source))));`, + ]); + const module = `export let A = "bar";\n`; + expect(result).toEqual(printed(JSON.stringify([module, module, `export let B = 1;\n`]))); + }); + }); + it("deletes dead exports and any imports only referenced in dead regions", () => { const output = transpiler.transformSync(` import deadFS from 'fs'; From 1eed0848917ca98750ba8f62d418c09824fbe89f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:47:40 +0000 Subject: [PATCH 2/4] js_parser: exports.eliminate and exports.replace leave a member of a namespace alone s_local selected the visit that applies the entries for each exported declaration, and a member of a TypeScript namespace is one. So `namespace NS { export let A = 1 }` lost `NS.A` with `eliminate: ["A"]`, and got `NS.A = 2` with `replace: { A: 2 }`. The visitors of a function and of a class skip a member of a namespace. s_local now selects that visit at module level only. The guard in the arm for a declaration with no initializer is not necessary any more. --- src/js_parser/visit/mod.rs | 3 +-- src/js_parser/visit/visit_stmt.rs | 6 +++++- test/bundler/transpiler/transpiler.test.js | 11 +++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 304564f96209..119a6178e9e8 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -598,8 +598,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, }, ); - } else if IS_POSSIBLY_DECL_TO_REMOVE && self.enclosing_namespace_arg_ref.is_none() { - // A namespace member with no value prints nothing, and a value would print as `NS.x = value`. + } else if IS_POSSIBLY_DECL_TO_REMOVE { if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index f399550709e3..b306d3ab72e5 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1138,7 +1138,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.cur_scope().is_after_const_local_prefix = was_after_after_const_local_prefix; // visit_decls returns the surviving decl count; truncate `data.decls.len` to it. - let new_len = if !(data.is_export && p.options.features.replace_exports.entries.len() > 0) { + // A member of a namespace is not an export of the module. + let new_len = if !(data.is_export + && p.options.features.replace_exports.entries.len() > 0 + && p.enclosing_namespace_arg_ref.is_none()) + { p.visit_decls::(data.decls.slice_mut(), data.kind, data.is_export) } else { p.visit_decls::(data.decls.slice_mut(), data.kind, data.is_export) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 629631e49226..338dda69aba9 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2413,6 +2413,17 @@ export default class { expect(await print(rows)).toEqual(expected(rows)); }); + it("a member of a namespace is not an export of the module", async () => { + const member = `var NS;\n((NS) => {\n NS.A = 1;\n})(NS ||= {});\n`; + const rows = [ + [eliminate(["A"]), `namespace NS { export let A = 1 }`, member, []], + [replace({ A: 2 }), `namespace NS { export let A = 1 }`, member, []], + [replace({ A: ["N", 2] }), `namespace NS { export const A = 1 }`, member, []], + [eliminate(["A"]), `export namespace NS { export let A = 1 } export let A = 2`, `export ${member}`, ["NS"]], + ]; + expect(await print(rows)).toEqual(expected(rows)); + }); + it("replace with a string, after the load of another module", async () => { using dir = tempDir("exports-replace-no-initializer", { "other.cjs": Array.from({ length: 50 }, (_, i) => `exports.v${i} = () => ["v", ${i}].join("-");`).join("\n"), From f2b387cd5a0791a02fa55e8c7ad795cca9d4a7d3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:42:55 +0000 Subject: [PATCH 3/4] js_parser: exports.eliminate keeps the body of a function or a class that is a member of a namespace s_function and s_class visited a member of a namespace as dead code when its name was in the entries, and then kept the statement. So `namespace NS { export function A() { return f() } }` with `eliminate: ["A"]` printed `function A() {}`. Both now look at the entries at module level only, as s_local does. The tests of the declaration with no initializer run in one child process. --- src/js_parser/visit/visit_stmt.rs | 2 + test/bundler/transpiler/transpiler.test.js | 78 +++++++++++++++------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index b306d3ab72e5..4feac200ae96 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -890,6 +890,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mark_as_dead = p.options.features.dead_code_elimination && data.func.flags.contains(flags::Function::IsExport) && p.options.features.replace_exports.count() > 0 + && p.enclosing_namespace_arg_ref.is_none() && p.is_export_to_eliminate(data.func.name.expect("infallible: name checked").ref_); let original_is_dead = p.is_control_flow_dead; @@ -1048,6 +1049,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mark_as_dead = p.options.features.dead_code_elimination && data.is_export && p.options.features.replace_exports.count() > 0 + && p.enclosing_namespace_arg_ref.is_none() && p.is_export_to_eliminate( data.class .class_name diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 338dda69aba9..f907ff3d1b98 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2361,21 +2361,44 @@ export default class { }; const ns = `var NS;\n((NS) => {})(NS ||= {});\n`; - // `eliminate` aborted the process on these inputs, so they run in a subprocess. + // [options, source, printed module, exports that scan() reports] + const rowsThatEliminate = [ + [eliminate(["A"]), `export let A`, ``, []], + [eliminate(["A"]), `export var A: number`, ``, []], + [eliminate(["A"], { loader: "js" }), `export let A; A = 1;`, `A = 1;\n`, []], + [eliminate(["A"]), `export let A, B = 1`, `export let B = 1;\n`, ["B"]], + [eliminate(["A", "B"], { treeShaking: false }), `export let A, B`, ``, []], + [eliminate(["A"], { deadCodeElimination: false }), `export let A`, ``, []], + [eliminate(["A"]), `namespace NS { export let A }`, ns, []], + [eliminate(["A"]), `namespace NS { export declare const A: number }`, ns, []], + ]; + const sourcesAfterALoad = ["export let A", "export let A = 5", "export let B"]; + + // `eliminate` aborted the process, so these inputs run in one subprocess. It prints one line for each test. + let subprocess; + const run = () => + (subprocess ??= (async () => { + using dir = tempDir("exports-no-initializer", { + "other.cjs": Array.from({ length: 50 }, (_, i) => `exports.v${i} = () => ["v", ${i}].join("-");`).join( + "\n", + ), + }); + const script = async (print, rows, sources, other) => { + console.log(JSON.stringify(await print(rows))); + + const transpiler = new Bun.Transpiler({ loader: "ts", exports: { replace: { A: "bar", B: 1 } } }); + require(other); + console.log(JSON.stringify(sources.map(source => transpiler.transformSync(source)))); + }; + const args = [rowsThatEliminate, sourcesAfterALoad, join(String(dir), "other.cjs")]; + return await bunRun(["-e", `await (${script})(${print}, ...${JSON.stringify(args)})`]); + })()); + it("eliminate", async () => { - // [options, source, printed module, exports that scan() reports] - const rows = [ - [eliminate(["A"]), `export let A`, ``, []], - [eliminate(["A"]), `export var A: number`, ``, []], - [eliminate(["A"], { loader: "js" }), `export let A; A = 1;`, `A = 1;\n`, []], - [eliminate(["A"]), `export let A, B = 1`, `export let B = 1;\n`, ["B"]], - [eliminate(["A", "B"], { treeShaking: false }), `export let A, B`, ``, []], - [eliminate(["A"], { deadCodeElimination: false }), `export let A`, ``, []], - [eliminate(["A"]), `namespace NS { export let A }`, ns, []], - [eliminate(["A"]), `namespace NS { export declare const A: number }`, ns, []], - ]; - const result = await bunRun(["-e", `console.log(JSON.stringify(await (${print})(${JSON.stringify(rows)})))`]); - expect(result).toEqual(printed(JSON.stringify(expected(rows)))); + const result = await run(); + expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual( + printed(JSON.stringify(expected(rowsThatEliminate))), + ); }); it("replace", async () => { @@ -2419,24 +2442,29 @@ export default class { [eliminate(["A"]), `namespace NS { export let A = 1 }`, member, []], [replace({ A: 2 }), `namespace NS { export let A = 1 }`, member, []], [replace({ A: ["N", 2] }), `namespace NS { export const A = 1 }`, member, []], + [ + eliminate(["A"]), + `namespace NS { export function A() { return f() } }`, + `var NS;\n((NS) => {\n function A() {\n return f();\n }\n NS.A = A;\n})(NS ||= {});\n`, + [], + ], + [ + replace({ K: 1 }), + `namespace NS { export class K { m() { return f() } } }`, + `var NS;\n((NS) => {\n\n class K {\n m() {\n return f();\n }\n }\n NS.K = K;\n})(NS ||= {});\n`, + [], + ], [eliminate(["A"]), `export namespace NS { export let A = 1 } export let A = 2`, `export ${member}`, ["NS"]], ]; expect(await print(rows)).toEqual(expected(rows)); }); it("replace with a string, after the load of another module", async () => { - using dir = tempDir("exports-replace-no-initializer", { - "other.cjs": Array.from({ length: 50 }, (_, i) => `exports.v${i} = () => ["v", ${i}].join("-");`).join("\n"), - }); - const result = await bunRun([ - "-e", - `const transpiler = new Bun.Transpiler({ loader: "ts", exports: { replace: { A: "bar", B: 1 } } }); - require(${JSON.stringify(join(String(dir), "other.cjs"))}); - const sources = ["export let A", "export let A = 5", "export let B"]; - console.log(JSON.stringify(sources.map(source => transpiler.transformSync(source))));`, - ]); const module = `export let A = "bar";\n`; - expect(result).toEqual(printed(JSON.stringify([module, module, `export let B = 1;\n`]))); + const result = await run(); + expect({ ...result, stdout: result.stdout.split("\n")[1] }).toEqual( + printed(JSON.stringify([module, module, `export let B = 1;\n`])), + ); }); }); From e87d32d6a829bbba3a6aefb6ec548a8967df0fdc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:47:31 +0000 Subject: [PATCH 4/4] js_parser: test for a member of a namespace inside visit_decls:: The test in s_local, where the visit is selected, cost s_local 3 instructions for each statement with no exports option. visit_decls now does the test once, and only in the instantiation that applies the entries. --- src/js_parser/visit/mod.rs | 9 ++++++--- src/js_parser/visit/visit_stmt.rs | 6 +----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 119a6178e9e8..73d2c6de6b59 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -279,6 +279,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O is_export: bool, ) -> usize { let was_const = kind == LocalKind::KConst; + // A member of a namespace is not an export of the module. + let applies_entries = + IS_POSSIBLY_DECL_TO_REMOVE && self.enclosing_namespace_arg_ref.is_none(); let mut j: usize = 0; // Iterate by index so kept entries can be written back through `decls[j]` // while scanning ahead. @@ -305,7 +308,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut replacement: Option< bun_ptr::BackRef, > = None; - if IS_POSSIBLY_DECL_TO_REMOVE { + if applies_entries { if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); @@ -571,7 +574,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - if IS_POSSIBLY_DECL_TO_REMOVE { + if applies_entries { self.is_control_flow_dead = orig_dead; if let BData::BIdentifier(_) = decl.binding.data { if let Some(_ptr) = replacement { @@ -598,7 +601,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, }, ); - } else if IS_POSSIBLY_DECL_TO_REMOVE { + } else if applies_entries { if let BData::BIdentifier(id) = decl.binding.data { let id_ref = id.r#ref; let name = self.load_name_from_ref(id_ref); diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 4feac200ae96..d3b6c88986c0 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1140,11 +1140,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.cur_scope().is_after_const_local_prefix = was_after_after_const_local_prefix; // visit_decls returns the surviving decl count; truncate `data.decls.len` to it. - // A member of a namespace is not an export of the module. - let new_len = if !(data.is_export - && p.options.features.replace_exports.entries.len() > 0 - && p.enclosing_namespace_arg_ref.is_none()) - { + let new_len = if !(data.is_export && p.options.features.replace_exports.entries.len() > 0) { p.visit_decls::(data.decls.slice_mut(), data.kind, data.is_export) } else { p.visit_decls::(data.decls.slice_mut(), data.kind, data.is_export)