From a3d89049cefc63ac3a23e1bd08ebe41a063ac0dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:54:50 +0000 Subject: [PATCH 01/14] js_parser: declare a replaced re-export by its exported name s_export_from passed the parse-time name ref of a list item to inject_replacement_export. That ref is not a symbol: its index is the length of the name. visit_binding indexed the symbol table with it, so a name longer than the table aborted the process and a shorter name read an unrelated symbol. The ref also named the imported binding, so `export { a as b } from` declared `a`. s_export_from and s_export_star now call one function. For an entry that replaces, it declares the exported name, as s_export_star did. For an entry that eliminates or injects, it declares nothing. An export with the name `default` goes to the visitor of `export default` as `export default `, so the lowering of a top-level `using` applies to it. --- src/js_parser/visit/visit_stmt.rs | 53 +++++-- test/bundler/transpiler/transpiler.test.js | 175 +++++++++++++++++++++ 2 files changed, 217 insertions(+), 11 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index f399550709e3..015edad1469a 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -2,6 +2,7 @@ use crate::Error; use crate::lexer as js_lexer; use crate::p::{P, ReactRefreshExportKind}; +use crate::parser::Runtime::ReplaceableExport; use crate::parser::{ PrependTempRefsOpts, ReactRefresh, Ref, RelocateVarsMode, SideEffects, StmtsKind, statement_cares_about_scope, @@ -265,6 +266,45 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Ok(()) } + /// Puts the export of `entry` in the place of `alias`, a name that a re-export exports and the module does not bind. + #[cold] + #[inline(never)] + fn replace_reexport( + p: &mut Self, + stmts: &mut StmtList<'a>, + alias: &'a [u8], + entry: &ReplaceableExport, + ) { + let exported_name: &[u8] = match entry { + ReplaceableExport::Inject { name, .. } => name, + ReplaceableExport::Replace(_) | ReplaceableExport::Delete => alias, + }; + match entry { + ReplaceableExport::Replace(value) | ReplaceableExport::Inject { value, .. } + if exported_name == js_ast::ClauseItem::DEFAULT_ALIAS => + { + let mut export_default = p.s( + S::ExportDefault { + default_name: js_ast::LocRef::default(), + value: js_ast::StmtOrExpr::Expr(*value), + }, + bun_ast::Loc::EMPTY, + ); + p.visit_and_append_stmt(stmts, &mut export_default) + .expect("unreachable"); + } + ReplaceableExport::Replace(_) => { + let declared = p + .declare_symbol(js_ast::symbol::Kind::Other, bun_ast::Loc::EMPTY, alias) + .expect("unreachable"); + let _ = p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, entry); + } + ReplaceableExport::Delete | ReplaceableExport::Inject { .. } => { + let _ = p.inject_replacement_export(stmts, Ref::NONE, bun_ast::Loc::EMPTY, entry); + } + } + } + fn s_export_from( p: &mut Self, stmts: &mut StmtList<'a>, @@ -289,8 +329,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // alias is arena-owned (`ArenaStr`), valid for 'a. let alias = items[i].alias.slice(); if let Some(entry) = p.options.features.replace_exports.get_ptr(alias).cloned() { - let _ = - p.inject_replacement_export(stmts, old_ref, bun_ast::Loc::EMPTY, &entry); + Self::replace_reexport(p, stmts, alias, &entry); continue; } @@ -345,15 +384,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O .get_ptr(alias_name) .cloned() { - let declared = p - .declare_symbol( - js_ast::symbol::Kind::Other, - bun_ast::Loc::EMPTY, - alias_name, - ) - .expect("unreachable"); - let _ = - p.inject_replacement_export(stmts, declared, bun_ast::Loc::EMPTY, &entry); + Self::replace_reexport(p, stmts, alias_name, &entry); return Ok(()); } } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2ec82493d4ff..10f31fedaa22 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2330,6 +2330,181 @@ export default class { trimUnusedImports: true, }); + describe("a name that a re-export exports", () => { + const replace = (entries, options) => ({ exports: { replace: entries }, ...options }); + const printed = stdout => ({ stdout, stderr: "", exitCode: 0, signalCode: null }); + + // [options, source, printed module, exports that scan() reports] + const rows = [ + [ + replace({ ReadStream: "x" }), + `export { ReadStream, WriteStream } from "node:fs"`, + `export var ReadStream = "x";\nexport { WriteStream } from "node:fs";\n`, + ["ReadStream", "WriteStream"], + ], + [ + replace({ a: 1, c: 3 }), + `export { a, b, c } from "x"`, + `export var a = 1;\nexport var c = 3;\nexport { b } from "x";\n`, + ["a", "b", "c"], + ], + [ + replace({ bb: 1 }), + `export { a as bb, b } from "x"`, + `export var bb = 1;\nexport { b } from "x";\n`, + ["b", "bb"], + ], + [replace({ a: 1 }), `export { a as bb } from "x"`, `export { a as bb } from "x";\n`, ["bb"]], + [ + replace({ b: 1 }), + `export { "s t" as b, c } from "x"`, + `export var b = 1;\nexport { c } from "x";\n`, + ["b", "c"], + ], + [ + replace({ abc: 1 }), + `export { \\u0061bc, c } from "x"`, + `export var abc = 1;\nexport { c } from "x";\n`, + ["abc", "c"], + ], + [ + replace({ a: 1 }), + `export { default as a, b } from "x"`, + `export var a = 1;\nexport { b } from "x";\n`, + ["a", "b"], + ], + [ + replace({ default: 1 }), + `export { default, b } from "x"`, + `export default 1;\nexport { b } from "x";\n`, + ["b", "default"], + ], + [ + replace({ default: 1 }), + `export { a as default, b } from "x"`, + `export default 1;\nexport { b } from "x";\n`, + ["b", "default"], + ], + [ + replace({ a: ["default", 1] }), + `export { a, b } from "x"`, + `export default 1;\nexport { b } from "x";\n`, + ["b", "default"], + ], + [ + replace({ a: ["N", true] }), + `export { a, b } from "x"`, + `export var N = true;\nexport { b } from "x";\n`, + ["N", "b"], + ], + [ + { exports: { eliminate: ["bb", "default"] } }, + `export { a as bb, c, default } from "x"`, + `export { c } from "x";\n`, + ["c"], + ], + [replace({ ns: 1 }), `export * as ns from "x"`, `export var ns = 1;\n`, ["ns"]], + [replace({ default: 1 }), `export * as default from "x"`, `export default 1;\n`, ["default"]], + [replace({ ns: ["default", 1] }), `export * as ns from "x"`, `export default 1;\n`, ["default"]], + // An entry that eliminates or injects does not declare the name of the re-export. + [{ exports: { eliminate: ["ns"] } }, `const ns = 1; export * as ns from "x"`, `const ns = 1;\n`, []], + [ + replace({ ns: ["N", 2] }), + `const ns = 1; export * as ns from "x"`, + `const ns = 1;\nexport var N = 2;\n`, + ["N"], + ], + [ + replace({ ReadStream: 1 }, { minify: { identifiers: true } }), + `const local = f(); g(local); export { ReadStream, WriteStream } from "node:fs"`, + `const e = f();\ng(e);\nexport var ReadStream = 1;\nexport { WriteStream } from "node:fs";\n`, + ["ReadStream", "WriteStream"], + ], + [ + replace({ a: 1 }, { treeShaking: false }), + `export { a, b } from "x"`, + `export var a = 1;\nexport { b } from "x";\n`, + ["a", "b"], + ], + ]; + + // [options, source, exports of the printed module] + // The lowering of a top-level `using` prints helper names, so these rows compare what the module exports. + const rowsWithUsing = [ + [ + replace({ default: 1, b: 2 }), + `using r = null; export { a as default, b } from "./x.mjs"`, + { b: 2, default: 1 }, + ], + [replace({ default: 1 }), `await using r = null; export * as default from "./x.mjs"`, { default: 1 }], + [replace({ a: ["default", 1] }), `using r = null; export { a, b } from "./x.mjs"`, { b: "b", default: 1 }], + ]; + + // [options, source, exports of the printed module]. The comment of a row is what it does today. + const rowsThatCannotBind = [ + // prints: export var class = 1; + [replace({ class: 1 }), `export { a as class, b } from "x"`, ["b", "class"]], + // throws: "bb" has already been declared + [replace({ bb: 1 }), `let bb = 2; export { a as bb } from "x"`, ["bb"]], + // prints: export var default = 1; + [replace({ g: ["default", 1] }), `export function g() {}`, ["default"]], + // prints: export const default = 1; + [replace({ x: ["default", 1] }), `export const x = 5`, ["default"]], + ]; + + // The first row aborted the process, so the rows run in one subprocess. It prints one line for each test. + let subprocess; + const run = () => + (subprocess ??= (async () => { + using dir = tempDir("exports-replace-reexport", { + "x.mjs": `export const a = "a", b = "b"; export default "x";`, + }); + const print = async (rows, rowsWithUsing, rowsThatCannotBind, dir) => { + const modules = []; + for (const loader of ["ts", "js"]) { + for (const [options, source] of rows) { + const transpiler = new Bun.Transpiler({ loader, ...options }); + modules.push([transpiler.transformSync(source), transpiler.scan(source).exports]); + } + } + const [options, source] = rows[0]; + modules.push(await new Bun.Transpiler({ loader: "ts", ...options }).transform(source)); + for (const [i, [options, source]] of rowsWithUsing.entries()) { + const file = require("path").join(dir, "module-" + i + ".mjs"); + await Bun.write(file, new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source)); + modules.push({ ...(await import(file)) }); + } + console.log(JSON.stringify(modules)); + + const names = rowsThatCannotBind.map(([options, source]) => { + try { + const module = new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source); + return new Bun.Transpiler({ loader: "ts" }).scan(module).exports; + } catch (error) { + return error.message; + } + }); + console.log(JSON.stringify(names)); + }; + const args = [rows, rowsWithUsing, rowsThatCannotBind, String(dir)]; + return await bunRun(["-e", `await (${print})(...${JSON.stringify(args)})`]); + })()); + + it("prints the export under its exported name", async () => { + const modules = rows.map(([, , module, names]) => [module, names]); + const exported = rowsWithUsing.map(([, , exported]) => exported); + const result = await run(); + expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual( + printed(JSON.stringify([...modules, ...modules, rows[0][2], ...exported])), + ); + }); + + it.failing("a name that the module cannot bind", async () => { + const result = await run(); + expect(result.stdout.split("\n")[1]).toBe(JSON.stringify(rowsThatCannotBind.map(([, , names]) => names))); + }); + }); + it("a deletes dead exports and any imports only referenced in dead regions", () => { const out = transpiler.transformSync(` import {getUserById} from './my-database'; From 1de33bfbcb8ecaac10f828a56bb8711ff77336ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:45:44 +0000 Subject: [PATCH 02/14] js_parser: apply no second entry of exports.replace to the default export that an entry makes The visitor of `export default` looks `default` up in the map. So an entry with the key `default` also applied to the `export default` that replace_reexport made for another entry. An entry that replaces changed the value again. An entry that eliminates or injects dropped the export when dead code elimination is on. The visitor now skips the lookup while replace_reexport visits its statement. --- src/js_parser/p.rs | 4 ++++ src/js_parser/visit/visit_stmt.rs | 4 +++- test/bundler/transpiler/transpiler.test.js | 24 ++++++++++++++++------ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2383804eb65e..81858af18866 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -716,6 +716,9 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { // If this is true, then all top-level statements are wrapped in a try/catch pub(crate) will_wrap_module_in_try_catch_for_using: bool, + /// The statement in visit is the export that an entry of `exports.replace` made, so no entry applies to it. + pub(crate) is_visiting_replaced_export: bool, + /// Used for react refresh, it must be able to insert `const _s = $RefreshSig$();` pub(crate) nearest_stmt_list: Option>>, // Lifetime caution: points at a stack local saved/restored across calls. @@ -9908,6 +9911,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O top_level_enums: BumpVec::new_in(arena), scopes_in_order_for_enum: Default::default(), will_wrap_module_in_try_catch_for_using: false, + is_visiting_replaced_export: false, nearest_stmt_list: None, decorator_class_name: None, diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 015edad1469a..4a316c959a31 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -290,8 +290,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, bun_ast::Loc::EMPTY, ); + p.is_visiting_replaced_export = true; p.visit_and_append_stmt(stmts, &mut export_default) .expect("unreachable"); + p.is_visiting_replaced_export = false; } ReplaceableExport::Replace(_) => { let declared = p @@ -415,7 +417,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut mark_for_replace: bool = false; let orig_dead = p.is_control_flow_dead; - if p.options.features.replace_exports.count() > 0 { + if p.options.features.replace_exports.count() > 0 && !p.is_visiting_replaced_export { if let Some(entry) = p.options.features.replace_exports.get_ptr(b"default") { p.is_control_flow_dead = p.options.features.dead_code_elimination && !entry.is_replace(); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 10f31fedaa22..9b3a9b9af3fe 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2391,6 +2391,19 @@ export default class { `export default 1;\nexport { b } from "x";\n`, ["b", "default"], ], + // The entry that makes `export default` is the only entry that applies to it. + [ + replace({ a: ["default", 1], default: 2 }), + `export { a, b } from "x"`, + `export default 1;\nexport { b } from "x";\n`, + ["b", "default"], + ], + [ + replace({ a: ["default", 1], default: ["N", 2] }), + `export { a, b } from "x"`, + `export default 1;\nexport { b } from "x";\n`, + ["b", "default"], + ], [ replace({ a: ["N", true] }), `export { a, b } from "x"`, @@ -2461,13 +2474,12 @@ export default class { }); const print = async (rows, rowsWithUsing, rowsThatCannotBind, dir) => { const modules = []; - for (const loader of ["ts", "js"]) { - for (const [options, source] of rows) { - const transpiler = new Bun.Transpiler({ loader, ...options }); - modules.push([transpiler.transformSync(source), transpiler.scan(source).exports]); - } + for (const [options, source] of rows) { + const transpiler = new Bun.Transpiler({ loader: "ts", ...options }); + modules.push([transpiler.transformSync(source), transpiler.scan(source).exports]); } const [options, source] = rows[0]; + modules.push(new Bun.Transpiler({ loader: "js", ...options }).transformSync(source)); modules.push(await new Bun.Transpiler({ loader: "ts", ...options }).transform(source)); for (const [i, [options, source]] of rowsWithUsing.entries()) { const file = require("path").join(dir, "module-" + i + ".mjs"); @@ -2495,7 +2507,7 @@ export default class { const exported = rowsWithUsing.map(([, , exported]) => exported); const result = await run(); expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual( - printed(JSON.stringify([...modules, ...modules, rows[0][2], ...exported])), + printed(JSON.stringify([...modules, rows[0][2], rows[0][2], ...exported])), ); }); From 10f03700b360c0ad655ccdfd82fbed1ecf3ac7ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:22:57 +0000 Subject: [PATCH 03/14] test: an unhealthy subprocess is not the failure that the it.failing test expects --- test/bundler/transpiler/transpiler.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 9b3a9b9af3fe..b1c7b81e6b28 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2513,7 +2513,10 @@ export default class { it.failing("a name that the module cannot bind", async () => { const result = await run(); - expect(result.stdout.split("\n")[1]).toBe(JSON.stringify(rowsThatCannotBind.map(([, , names]) => names))); + const [, names] = result.stdout.split("\n"); + // A subprocess that did not print the names is not the failure that this test expects. + if (result.exitCode !== 0 || names === undefined) return; + expect(names).toBe(JSON.stringify(rowsThatCannotBind.map(([, , names]) => names))); }); }); From e1527b332623363737c9941b88197b8598225554 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:42:44 +0000 Subject: [PATCH 04/14] js_parser: report an import of the name of a replaced re-export with the ts loader too The ts loader lets a declaration take the place of an import of its name. So `import { a } from "x"; export { a, b } from "x"` with `replace: { a: 1 }` printed a module with no import, and each use of `a` read the replaced export. replace_reexport now reports `"a" has already been declared`, as the js loader does. The flag on the parser is gone. replace_reexport takes the entries away while the visitor of `export default` runs, so that visitor is the code of main again. --- src/js_parser/p.rs | 4 --- src/js_parser/visit/visit_stmt.rs | 23 ++++++++++++++--- test/bundler/transpiler/transpiler.test.js | 30 +++++++++++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 81858af18866..2383804eb65e 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -716,9 +716,6 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { // If this is true, then all top-level statements are wrapped in a try/catch pub(crate) will_wrap_module_in_try_catch_for_using: bool, - /// The statement in visit is the export that an entry of `exports.replace` made, so no entry applies to it. - pub(crate) is_visiting_replaced_export: bool, - /// Used for react refresh, it must be able to insert `const _s = $RefreshSig$();` pub(crate) nearest_stmt_list: Option>>, // Lifetime caution: points at a stack local saved/restored across calls. @@ -9911,7 +9908,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O top_level_enums: BumpVec::new_in(arena), scopes_in_order_for_enum: Default::default(), will_wrap_module_in_try_catch_for_using: false, - is_visiting_replaced_export: false, nearest_stmt_list: None, decorator_class_name: None, diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 4a316c959a31..dc76076e29eb 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -290,12 +290,29 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }, bun_ast::Loc::EMPTY, ); - p.is_visiting_replaced_export = true; + // No entry applies to the export that an entry made, so the visitor sees no entries. + let replace_exports = core::mem::take(&mut p.options.features.replace_exports); p.visit_and_append_stmt(stmts, &mut export_default) .expect("unreachable"); - p.is_visiting_replaced_export = false; + p.options.features.replace_exports = replace_exports; } ReplaceableExport::Replace(_) => { + // TypeScript lets a declaration take the place of an import of its name. This export is not one. + if TYPESCRIPT + && let Some(import) = p + .current_scope() + .get_member_with_hash(alias, js_ast::Scope::get_member_hash(alias)) + && p.symbols[import.ref_.inner_index() as usize].kind + == js_ast::symbol::Kind::Import + { + p.log().add_symbol_already_declared_error( + p.source, + alias, + bun_ast::Loc::EMPTY, + import.loc, + ); + return; + } let declared = p .declare_symbol(js_ast::symbol::Kind::Other, bun_ast::Loc::EMPTY, alias) .expect("unreachable"); @@ -417,7 +434,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut mark_for_replace: bool = false; let orig_dead = p.is_control_flow_dead; - if p.options.features.replace_exports.count() > 0 && !p.is_visiting_replaced_export { + if p.options.features.replace_exports.count() > 0 { if let Some(entry) = p.options.features.replace_exports.get_ptr(b"default") { p.is_control_flow_dead = p.options.features.dead_code_elimination && !entry.is_replace(); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index b1c7b81e6b28..08bcb2ed5746 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2453,6 +2453,20 @@ export default class { [replace({ a: ["default", 1] }), `using r = null; export { a, b } from "./x.mjs"`, { b: "b", default: 1 }], ]; + // [options, source, message of the error]. An import of the name is a collision for both loaders. + const rowsThatThrow = [ + [ + replace({ a: 1 }), + `import { a } from "x"; export { a, b } from "x"; export const t = a`, + `"a" has already been declared`, + ], + [ + replace({ ns: 1 }), + `import { ns } from "y"; export * as ns from "x"; export const t = ns`, + `"ns" has already been declared`, + ], + ]; + // [options, source, exports of the printed module]. The comment of a row is what it does today. const rowsThatCannotBind = [ // prints: export var class = 1; @@ -2472,7 +2486,7 @@ export default class { using dir = tempDir("exports-replace-reexport", { "x.mjs": `export const a = "a", b = "b"; export default "x";`, }); - const print = async (rows, rowsWithUsing, rowsThatCannotBind, dir) => { + const print = async (rows, rowsWithUsing, rowsThatThrow, rowsThatCannotBind, dir) => { const modules = []; for (const [options, source] of rows) { const transpiler = new Bun.Transpiler({ loader: "ts", ...options }); @@ -2486,6 +2500,15 @@ export default class { await Bun.write(file, new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source)); modules.push({ ...(await import(file)) }); } + for (const loader of ["ts", "js"]) { + for (const [options, source] of rowsThatThrow) { + try { + modules.push([new Bun.Transpiler({ loader, ...options }).transformSync(source)]); + } catch (error) { + modules.push(error.message); + } + } + } console.log(JSON.stringify(modules)); const names = rowsThatCannotBind.map(([options, source]) => { @@ -2498,16 +2521,17 @@ export default class { }); console.log(JSON.stringify(names)); }; - const args = [rows, rowsWithUsing, rowsThatCannotBind, String(dir)]; + const args = [rows, rowsWithUsing, rowsThatThrow, rowsThatCannotBind, String(dir)]; return await bunRun(["-e", `await (${print})(...${JSON.stringify(args)})`]); })()); it("prints the export under its exported name", async () => { const modules = rows.map(([, , module, names]) => [module, names]); const exported = rowsWithUsing.map(([, , exported]) => exported); + const messages = rowsThatThrow.map(([, , message]) => message); const result = await run(); expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual( - printed(JSON.stringify([...modules, rows[0][2], rows[0][2], ...exported])), + printed(JSON.stringify([...modules, rows[0][2], rows[0][2], ...exported, ...messages, ...messages])), ); }); From 59da67659acb709c2874de372d84fcabe52d97f7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:14:41 +0000 Subject: [PATCH 05/14] Bun.Transpiler: allocate exports.replace string values in the instance arena export_replacement_value copied the string bytes into the JSTranspiler's arena but built the E::EString node with Expr::init, which appends the node to the thread-local AST store. The Expr is kept in config.runtime.replace_exports for the life of the transpiler, while the next synchronous parse on the thread (a require() of a CommonJS file, a JSON import, ...) resets that store, so later transforms printed the replacement from a freed slot. Allocate the node in the same arena as its bytes with Expr::allocate; the arena lives exactly as long as the config that holds the Expr. Disabler::scope() makes a debug build panic if anything in this function appends to the thread-local Expr store again. The required module in the test declares enough strings to reuse the slot the replacement node used to occupy, so the test also fails on a release build without the fix. --- src/ast/expr.rs | 2 +- src/runtime/api/JSTranspiler.rs | 6 +- test/bundler/transpiler/transpiler.test.js | 67 ++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/ast/expr.rs b/src/ast/expr.rs index cfedeb4f9bf8..a26cc90151a4 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -1039,7 +1039,7 @@ impl IntoExprData for &E::EString { impl Expr { /// When the lifetime of an Expr.Data's pointer must exist longer than reset() is called, use this function. /// Be careful to free the memory (or use an arena that does it for you) - /// Also, prefer Expr.init or Expr.alloc when possible. This will be slower. + /// Prefer `Expr::init` for nodes that only need to live until the current parse's reset(). #[inline] pub fn allocate(bump: &Bump, st: T, loc: Loc) -> Expr { data::Store::assert(); diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index df5d4bc5f529..d3327a117f5c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -878,6 +878,9 @@ fn export_replacement_value( global: &JSGlobalObject, arena: &Arena, ) -> JsResult> { + // The result outlives every parse, so it must not be built in the thread-local store. + let _guard = bun_ast::expr::Disabler::scope(); + if value.is_boolean() { return Ok(Some(Expr { data: bun_ast::ExprData::EBoolean(bun_ast::E::Boolean { @@ -916,7 +919,8 @@ fn export_replacement_value( // `E::EString::init` erases the borrow to `'static` per the AST // crate's `Str` convention (see ast/E.rs). let data = arena.alloc_slice_copy(utf8.slice()); - return Ok(Some(Expr::init( + return Ok(Some(Expr::allocate( + arena, bun_ast::E::EString::init(data), bun_ast::Loc::EMPTY, ))); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 08bcb2ed5746..ec962219efd7 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2618,6 +2618,73 @@ export default class { expect(output.includes("localVarToReplace")).toBe(true); expect(output.includes("localVarToRemove")).toBe(false); }); + + it("string replacement values survive other modules being loaded after the transpiler is created", async () => { + // String values are the only replacement values that are heap-allocated + // AST nodes. They used to be allocated in the thread-local AST store, + // which every synchronous parse on the main thread (a require() of a + // CommonJS file, an import of a JSON file, ...) resets and then refills + // with that module's own nodes, so a later transform printed the + // replacement out of memory that by then held some other node. Debug + // builds poison the store on reset, so any reset exposes the stale read; + // release builds only misbehave once the slot has been reused, which is + // why the required module declares a few hundred strings (a few dozen + // are enough to reach the slot). + const resetLines = []; + for (let i = 0; i < 300; i++) { + resetLines.push(`const s${i} = "other string ${i}";`); + } + resetLines.push("module.exports = { s0, s299 };"); + + using dir = tempDir("transpiler-replace-string-values", { + "reset.cjs": resetLines.join("\n"), + "reset.json": `{}`, + "entry.mjs": ` + const transpiler = new Bun.Transpiler({ + exports: { + replace: { + foo: "bar", + getStaticProps: ["__N_SSG", "ssg"], + default: "dflt", + }, + }, + }); + + require("./reset.cjs"); + await import("./reset.json"); + + console.log( + JSON.stringify([ + transpiler.transformSync("export const foo = 1;"), + transpiler.transformSync("export function getStaticProps() {}"), + transpiler.transformSync("export default 1;"), + await transpiler.transform("export const foo = 1;"), + await transpiler.transform("export function getStaticProps() {}"), + await transpiler.transform("export default 1;"), + ]), + ); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([ + 'export const foo = "bar";\n', + 'export var __N_SSG = "ssg";\n', + 'export default "dflt";\n', + 'export const foo = "bar";\n', + 'export var __N_SSG = "ssg";\n', + 'export default "dflt";\n', + ]); + expect(exitCode).toBe(0); + }); }); const bunTranspiler = new Bun.Transpiler({ From e946a117e7d76de3e3b0e1468b79f49a16306d12 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:00:13 +0000 Subject: [PATCH 06/14] test: shorten the comment in the exports.replace string test --- test/bundler/transpiler/transpiler.test.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ec962219efd7..c2d289da11dd 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2620,16 +2620,7 @@ export default class { }); it("string replacement values survive other modules being loaded after the transpiler is created", async () => { - // String values are the only replacement values that are heap-allocated - // AST nodes. They used to be allocated in the thread-local AST store, - // which every synchronous parse on the main thread (a require() of a - // CommonJS file, an import of a JSON file, ...) resets and then refills - // with that module's own nodes, so a later transform printed the - // replacement out of memory that by then held some other node. Debug - // builds poison the store on reset, so any reset exposes the stale read; - // release builds only misbehave once the slot has been reused, which is - // why the required module declares a few hundred strings (a few dozen - // are enough to reach the slot). + // Release builds only reuse the AST store slot after a few dozen string declarations. const resetLines = []; for (let i = 0; i < 300; i++) { resetLines.push(`const s${i} = "other string ${i}";`); From 540701ecd26219f2749618b3564d3aa34a797468 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 07/14] 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 c2d289da11dd..36763da13dbc 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2559,6 +2559,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 a219ce3d93d560dec8d61ce55a47f60242ab2b66 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 08/14] 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 dc76076e29eb..50673c815e39 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1188,7 +1188,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 36763da13dbc..9ff84c7c1282 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2627,6 +2627,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 0c94fd88403b22890c0cf47ef211fef191a002e3 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 09/14] 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 50673c815e39..90553c250cb9 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -940,6 +940,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; @@ -1098,6 +1099,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 9ff84c7c1282..f7777abf5e38 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2575,21 +2575,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 () => { @@ -2633,24 +2656,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 aa893366977676467446be31e028b7e55d9cc6ab 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 10/14] 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 90553c250cb9..07112616fe14 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1190,11 +1190,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) From c67695af0f94c2a331a1a95c79aa42d9007ac9a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:40:42 +0000 Subject: [PATCH 11/14] js_parser: don't move a part that exports.eliminate removed append_part() appends nothing when every statement in the part was eliminated while visiting, but the SClass and SExportDefault arms of _parse() popped the part unconditionally when hoisting it to the top of the file. With exports.eliminate, the hoistable export is gone by then, so the pop either aborted the process on an empty list or stole the previous statement's part and reordered the output. append_part() now reports whether it appended, and the two hoisting sites only move a part that exists. --- src/js_parser/p.rs | 8 ++-- src/js_parser/parse/parse_entry.rs | 12 +++--- test/bundler/transpiler/transpiler.test.js | 49 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 2383804eb65e..e3af60abaa20 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -5586,11 +5586,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + /// Returns `false` when visiting eliminated every statement, so no part was pushed. pub(crate) fn append_part( &mut self, parts: &mut ListManaged<'a, js_ast::Part>, stmts: &'a mut [Stmt], - ) -> Result<(), crate::Error> { + ) -> Result { // Uses recorded outside a part's visit (the parse pass resolving // decorator-metadata types) belong to no part. self.part_uses.clear(); @@ -5652,7 +5653,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.relocated_top_level_vars.clear(); } - if !part_stmts.is_empty() { + let appended = !part_stmts.is_empty(); + if appended { // SAFETY: `into_bump_slice_mut` leaks the BumpVec into the arena and // returns the unique `&'a mut [T]` for that allocation. We compute // `can_be_removed_if_unused` while the `&mut` is live (reborrowed as @@ -5711,7 +5713,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.declared_symbols.clear_retaining_capacity(); self.import_records_for_current_part.clear(); } - Ok(()) + Ok(appended) } /// A pattern runs getters or the iterator on its value, so only a literal value is side-effect free. diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index e1c242d36853..2e60fe2acd4a 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1157,11 +1157,11 @@ impl<'a> Parser<'a> { let should_move = !p.options.bundle && class.class.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; + let appended = p.append_part(&mut parts, sliced)?; - if should_move { + if should_move && appended { // `Part` isn't `Copy`; pop+push instead of last+truncate. - before.push(parts.pop().expect("unreachable")); + before.push(parts.pop().expect("infallible: part was appended")); } } js_ast::StmtData::SExportDefault(value) => { @@ -1170,10 +1170,10 @@ impl<'a> Parser<'a> { // https://github.com/oven-sh/bun/issues/1961 let should_move = !p.options.bundle && value.can_be_moved(); let sliced = arena.alloc_slice_copy(&[*stmt]); - p.append_part(&mut parts, sliced)?; + let appended = p.append_part(&mut parts, sliced)?; - if should_move { - before.push(parts.pop().expect("unreachable")); + if should_move && appended { + before.push(parts.pop().expect("infallible: part was appended")); } } js_ast::StmtData::SEnum(_) => { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index f7777abf5e38..5c39370a19a3 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2801,6 +2801,55 @@ export default class { }); }); + describe("exports.eliminate", () => { + // Side-effect-free `export class` / `export default` statements are hoisted to + // the top of the file, but eliminating one leaves no part behind to hoist. + const hoistable = [ + ["class declaration", ["A"], `export class A {}\nexport const keep = 1;\n`], + ["default class", ["default"], `export default class A {}\nexport const keep = 1;\n`], + ["default anonymous class", ["default"], `export default class {}\nexport const keep = 1;\n`], + ["default function", ["default"], `export default function A() {}\nexport const keep = 1;\n`], + ["default arrow", ["default"], `export default () => {};\nexport const keep = 1;\n`], + ["default literal", ["default"], `export default 42;\nexport const keep = 1;\n`], + ]; + + describe.each(hoistable)("hoistable %s", (_label, names, source) => { + it.concurrent("is eliminated without crashing", async () => { + const script = ` + const out = new Bun.Transpiler({ loader: "ts", exports: { eliminate: ${JSON.stringify(names)} } }) + .transformSync(${JSON.stringify(source)}); + console.write(JSON.stringify(out)); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Surfaces the panic message instead of a JSON.parse error below. + if (exitCode !== 0) expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toBe("export const keep = 1;\n"); + expect(exitCode).toBe(0); + }); + }); + + const reordered = [ + ["class declaration", ["A"], `console.log("one");\nconsole.log("two");\nexport class A {}\n`], + ["default class", ["default"], `console.log("one");\nconsole.log("two");\nexport default class A {}\n`], + ]; + + describe.each(reordered)("hoistable %s", (_label, names, source) => { + it("keeps statement order when eliminated", () => { + const out = new Bun.Transpiler({ loader: "ts", exports: { eliminate: names } }).transformSync(source); + expect(out).toBe(`console.log("one");\nconsole.log("two");\n`); + }); + }); + }); + const bunTranspiler = new Bun.Transpiler({ loader: "tsx", define: { From 089fd53d8738afba9ac900cd0c997e1e2447131a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:08:53 +0000 Subject: [PATCH 12/14] test: run the hoistable exports.eliminate cases in one child process --- test/bundler/transpiler/transpiler.test.js | 33 ++++++++-------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 5c39370a19a3..ae4fb98c6487 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2813,27 +2813,18 @@ export default class { ["default literal", ["default"], `export default 42;\nexport const keep = 1;\n`], ]; - describe.each(hoistable)("hoistable %s", (_label, names, source) => { - it.concurrent("is eliminated without crashing", async () => { - const script = ` - const out = new Bun.Transpiler({ loader: "ts", exports: { eliminate: ${JSON.stringify(names)} } }) - .transformSync(${JSON.stringify(source)}); - console.write(JSON.stringify(out)); - `; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - // Surfaces the panic message instead of a JSON.parse error below. - if (exitCode !== 0) expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toBe("export const keep = 1;\n"); - expect(exitCode).toBe(0); + // Each of these aborted the process, so they run in one subprocess. + it("eliminates a hoistable export", async () => { + const print = rows => + rows.map(([, names, source]) => + new Bun.Transpiler({ loader: "ts", exports: { eliminate: names } }).transformSync(source), + ); + const result = await bunRun(["-e", `console.log(JSON.stringify((${print})(${JSON.stringify(hoistable)})))`]); + expect(result).toEqual({ + stdout: JSON.stringify(hoistable.map(() => "export const keep = 1;\n")), + stderr: "", + exitCode: 0, + signalCode: null, }); }); From 36bb20b772a8768b9b8e05e6ce05283184a4f3a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:30:11 +0000 Subject: [PATCH 13/14] js_parser: inject the export of an exports.replace entry for an export default with dead code elimination on The visitor of `export default` marks the value of the statement as dead code when an entry eliminates it or puts another export in its place, and it returned before it injected that export. So `export default 42` with `replace: { default: ["N", 1] }` lost the export. With dead code elimination off, the visitor injected it. The three exits for a dead value now inject the export of the entry. --- src/js_parser/visit/visit_stmt.rs | 26 ++++++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 26 +++++++++++++--------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index 07112616fe14..f2a246b76083 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -448,6 +448,29 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } + // The value that an entry takes away is dead code. The export that the entry injects is not. + macro_rules! inject_for_dead_value { + () => { + if mark_for_replace && !orig_dead { + if let Some(entry) = p + .options + .features + .replace_exports + .get_ptr(b"default") + .cloned() + && !entry.is_replace() + { + let _ = p.inject_replacement_export( + stmts, + Ref::NONE, + bun_ast::Loc::EMPTY, + &entry, + ); + } + } + }; + } + match &mut data.value { js_ast::StmtOrExpr::Expr(expr) => { let was_anonymous_named_expr = expr.is_anonymous_named(); @@ -477,6 +500,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -661,6 +685,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { p.react_refresh.hook_ctx_storage = prev; restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } @@ -830,6 +855,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if p.is_control_flow_dead { restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index ae4fb98c6487..6bef72381c61 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2804,24 +2804,28 @@ export default class { describe("exports.eliminate", () => { // Side-effect-free `export class` / `export default` statements are hoisted to // the top of the file, but eliminating one leaves no part behind to hoist. + // [exports, source, printed module] + const keep = `export const keep = 1;\n`; const hoistable = [ - ["class declaration", ["A"], `export class A {}\nexport const keep = 1;\n`], - ["default class", ["default"], `export default class A {}\nexport const keep = 1;\n`], - ["default anonymous class", ["default"], `export default class {}\nexport const keep = 1;\n`], - ["default function", ["default"], `export default function A() {}\nexport const keep = 1;\n`], - ["default arrow", ["default"], `export default () => {};\nexport const keep = 1;\n`], - ["default literal", ["default"], `export default 42;\nexport const keep = 1;\n`], + [{ eliminate: ["A"] }, `export class A {}\n${keep}`, keep], + [{ eliminate: ["default"] }, `export default class A {}\n${keep}`, keep], + [{ eliminate: ["default"] }, `export default class {}\n${keep}`, keep], + [{ eliminate: ["default"] }, `export default function A() {}\n${keep}`, keep], + [{ eliminate: ["default"] }, `export default () => {};\n${keep}`, keep], + [{ eliminate: ["default"] }, `export default 42;\n${keep}`, keep], + // An entry that injects takes the export away too, and its own export stays. + [{ replace: { default: ["N", 1] } }, `export default 42;\n${keep}`, `export var N = 1;\n${keep}`], + [{ replace: { default: ["N", 1] } }, `export default class A {}\n${keep}`, `export var N = 1;\n${keep}`], + [{ replace: { default: ["N", 1] } }, `export default function A() {}\n${keep}`, `export var N = 1;\n${keep}`], ]; // Each of these aborted the process, so they run in one subprocess. - it("eliminates a hoistable export", async () => { + it("takes a hoistable export away", async () => { const print = rows => - rows.map(([, names, source]) => - new Bun.Transpiler({ loader: "ts", exports: { eliminate: names } }).transformSync(source), - ); + rows.map(([exports, source]) => new Bun.Transpiler({ loader: "ts", exports }).transformSync(source)); const result = await bunRun(["-e", `console.log(JSON.stringify((${print})(${JSON.stringify(hoistable)})))`]); expect(result).toEqual({ - stdout: JSON.stringify(hoistable.map(() => "export const keep = 1;\n")), + stdout: JSON.stringify(hoistable.map(([, , module]) => module)), stderr: "", exitCode: 0, signalCode: null, From 603fb7f33238a9a412b10012cb22841080401910 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:58:08 +0000 Subject: [PATCH 14/14] js_parser: inject the export of an entry for a default export that is a type The exit of the visitor for `export default T`, where `T` is a type, dropped the statement and did not inject the export of the entry. --- src/js_parser/visit/visit_stmt.rs | 1 + test/bundler/transpiler/transpiler.test.js | 30 ++++++++++++++-------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index f2a246b76083..e88e5f567398 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -524,6 +524,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // this symbol, drop the statement. data.default_name.ref_ = Ref::NONE; restore_dead!(); + inject_for_dead_value!(); record_on_exit!(); return Ok(()); } diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 6bef72381c61..106c4f5297cd 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2804,25 +2804,33 @@ export default class { describe("exports.eliminate", () => { // Side-effect-free `export class` / `export default` statements are hoisted to // the top of the file, but eliminating one leaves no part behind to hoist. - // [exports, source, printed module] + // [options, source, printed module] const keep = `export const keep = 1;\n`; + const eliminate = names => ({ exports: { eliminate: names } }); + const inject = options => ({ exports: { replace: { default: ["N", 1] } }, ...options }); const hoistable = [ - [{ eliminate: ["A"] }, `export class A {}\n${keep}`, keep], - [{ eliminate: ["default"] }, `export default class A {}\n${keep}`, keep], - [{ eliminate: ["default"] }, `export default class {}\n${keep}`, keep], - [{ eliminate: ["default"] }, `export default function A() {}\n${keep}`, keep], - [{ eliminate: ["default"] }, `export default () => {};\n${keep}`, keep], - [{ eliminate: ["default"] }, `export default 42;\n${keep}`, keep], + [eliminate(["A"]), `export class A {}\n${keep}`, keep], + [eliminate(["default"]), `export default class A {}\n${keep}`, keep], + [eliminate(["default"]), `export default class {}\n${keep}`, keep], + [eliminate(["default"]), `export default function A() {}\n${keep}`, keep], + [eliminate(["default"]), `export default () => {};\n${keep}`, keep], + [eliminate(["default"]), `export default 42;\n${keep}`, keep], // An entry that injects takes the export away too, and its own export stays. - [{ replace: { default: ["N", 1] } }, `export default 42;\n${keep}`, `export var N = 1;\n${keep}`], - [{ replace: { default: ["N", 1] } }, `export default class A {}\n${keep}`, `export var N = 1;\n${keep}`], - [{ replace: { default: ["N", 1] } }, `export default function A() {}\n${keep}`, `export var N = 1;\n${keep}`], + [inject(), `export default 42;\n${keep}`, `export var N = 1;\n${keep}`], + [inject(), `export default class A {}\n${keep}`, `export var N = 1;\n${keep}`], + [inject(), `export default function A() {}\n${keep}`, `export var N = 1;\n${keep}`], + [inject(), `interface T {}\nexport default T;\n${keep}`, `export var N = 1;\n${keep}`], + [ + inject({ deadCodeElimination: false }), + `interface T {}\nexport default T;\n${keep}`, + `export var N = 1;\n${keep}`, + ], ]; // Each of these aborted the process, so they run in one subprocess. it("takes a hoistable export away", async () => { const print = rows => - rows.map(([exports, source]) => new Bun.Transpiler({ loader: "ts", exports }).transformSync(source)); + rows.map(([options, source]) => new Bun.Transpiler({ loader: "ts", ...options }).transformSync(source)); const result = await bunRun(["-e", `console.log(JSON.stringify((${print})(${JSON.stringify(hoistable)})))`]); expect(result).toEqual({ stdout: JSON.stringify(hoistable.map(([, , module]) => module)),