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/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/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index b6466e4c2b70..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); @@ -613,18 +616,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/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index f399550709e3..e88e5f567398 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,64 @@ 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, + ); + // 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.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"); + 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 +348,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 +403,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(()); } } @@ -398,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(); @@ -427,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(()); } @@ -450,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(()); } @@ -611,6 +686,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(()); } @@ -780,6 +856,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(()); } @@ -890,6 +967,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 +1126,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/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 2ec82493d4ff..106c4f5297cd 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2330,6 +2330,220 @@ 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"], + ], + // 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"`, + `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, 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; + [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, rowsThatThrow, rowsThatCannotBind, dir) => { + const modules = []; + 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"); + 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]) => { + 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, 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, ...messages, ...messages])), + ); + }); + + it.failing("a name that the module cannot bind", async () => { + const result = await run(); + 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))); + }); + }); + it("a deletes dead exports and any imports only referenced in dead regions", () => { const out = transpiler.transformSync(` import {getUserById} from './my-database'; @@ -2345,6 +2559,129 @@ 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`; + + // [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 () => { + const result = await run(); + expect({ ...result, stdout: result.stdout.split("\n")[0] }).toEqual( + printed(JSON.stringify(expected(rowsThatEliminate))), + ); + }); + + 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("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"]), + `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 () => { + const module = `export let A = "bar";\n`; + const result = await run(); + expect({ ...result, stdout: result.stdout.split("\n")[1] }).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'; @@ -2404,6 +2741,116 @@ 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 () => { + // 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}";`); + } + 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); + }); + }); + + 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. + // [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], + // An entry that injects takes the export away too, and its own export stays. + [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(([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)), + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + + 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({