diff --git a/docs/runtime/transpiler.mdx b/docs/runtime/transpiler.mdx index 1c27654541db..726079a4e6c8 100644 --- a/docs/runtime/transpiler.mdx +++ b/docs/runtime/transpiler.mdx @@ -207,13 +207,19 @@ interface TranspilerOptions { // Specify a set of exports to eliminate // Or rename certain exports + // Enables treeShaking by default, so code only used by an eliminated export is removed too exports?: { eliminate?: string[]; replace?: Record; }, + // Whether to remove top-level declarations (without side effects) that nothing + // exported or executed in the file refers to, and the imports they used + // Default: false, or true when exports has entries + treeShaking?: boolean, + // Whether to remove unused imports from transpiled file - // Default: false + // Default: the value of treeShaking trimUnusedImports?: boolean, // Whether to enable a set of JSX optimizations diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d8c9402e2871..d32e14152132 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -2728,7 +2728,24 @@ declare module "bun" { eliminate?: string[]; replace?: Record; }; + /** + * Remove top-level functions, classes and variables (with side-effect-free + * initializers) that nothing exported or executed by the file refers to, + * then the imports they were the last users of (unless + * {@link trimUnusedImports} is `false`). Combined with `exports.eliminate`, + * this also removes whatever only the eliminated exports used. Only the one + * file being transformed is considered, and a direct `eval()` anywhere in it + * disables the removal. + * + * @default false (`true` when {@link exports} has entries) + */ treeShaking?: boolean; + /** + * Remove imported bindings that the output does not use. Imports written + * without bindings (`import "./setup"`) are kept. + * + * Defaults to the value of {@link treeShaking}. + */ trimUnusedImports?: boolean; jsxOptimizationInline?: boolean; diff --git a/src/bundler/options.rs b/src/bundler/options.rs index b01ba4cdd85b..921ac200521c 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -1252,6 +1252,8 @@ pub struct BundleOptions<'a> { pub conditions: ESMConditions, pub tree_shaking: bool, pub tree_shaking_override: Option, + /// Bun.Transpiler's treeShaking; distinct from `tree_shaking`, which the runtime sets too. + pub remove_unused_declarations: bool, pub code_splitting: bool, pub source_map: SourceMapOption, pub packages: PackagesOption, @@ -1456,6 +1458,7 @@ impl<'a> BundleOptions<'a> { }, tree_shaking: self.tree_shaking, tree_shaking_override: self.tree_shaking_override, + remove_unused_declarations: self.remove_unused_declarations, code_splitting: self.code_splitting, source_map: self.source_map, packages: self.packages, @@ -1701,6 +1704,7 @@ impl<'a> BundleOptions<'a> { }, // filled below tree_shaking: false, tree_shaking_override: None, + remove_unused_declarations: false, code_splitting: false, source_map: SourceMapOption::None, packages: PackagesOption::Bundle, diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 2bf15e34c745..cd29f108279d 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1597,6 +1597,7 @@ impl<'a> Transpiler<'a> { .options .trim_unused_imports .unwrap_or_else(|| loader.is_typescript()); + opts.features.remove_unused_declarations = self.options.remove_unused_declarations; opts.features.no_macros = self.options.no_macros; // `bun_ast::RuntimeTranspilerCache` is the single nominal // type on both sides; thread the pointer directly. diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 9418128c3ccc..ba8e70e7fa67 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -1272,7 +1272,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.deoptimize_common_js_named_exports(); } - fn clear_symbol_usages_from_dead_part(&mut self, part: &js_ast::Part) { + pub(crate) fn clear_symbol_usages_from_dead_part(&mut self, part: &js_ast::Part) { let symbol_use_refs = part.symbol_uses.keys(); let symbol_use_values = part.symbol_uses.values(); let symbols = self.symbols.as_mut_slice(); @@ -7790,10 +7790,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let arena = self.arena; - // if (p.options.tree_shaking and p.options.features.trim_unused_imports) { - // p.treeShake(&parts, false); - // } - let bundling = self.options.bundle; let mut parts_end: usize = usize::from(bundling); diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 4bb47e5e0c0a..b85bbe184b8f 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -193,6 +193,7 @@ impl<'a> Options<'a> { dead_code_elimination: f.dead_code_elimination, set_breakpoint_on_first_line: f.set_breakpoint_on_first_line, trim_unused_imports: f.trim_unused_imports, + remove_unused_declarations: f.remove_unused_declarations, auto_polyfill_require: f.auto_polyfill_require, replace_exports: Default::default(), dont_bundle_twice: f.dont_bundle_twice, @@ -1122,6 +1123,11 @@ impl<'a> Parser<'a> { // `perf::Ctx` ends the span in its `Drop` impl — bind it for the rest of `_parse`. let _postvisit_tracer = bun_core::perf::trace("JSParser::postvisit"); + // Before anything below reads use counts (`__dirname`, `exports`, runtime helpers). + if p.options.features.remove_unused_declarations { + p.remove_unused_parts(&mut before, &mut parts); + } + let mut uses_dirname = p.symbols.as_slice()[p.dirname_ref.inner_index() as usize].use_count_estimate > 0; let mut uses_filename = diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 20db6ddf9d31..58b4269e2c88 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -209,6 +209,9 @@ pub mod Runtime { pub trim_unused_imports: bool, + /// Run `P::remove_unused_parts`; `Options.tree_shaking` alone only splits up parts. + pub remove_unused_declarations: bool, + /// Allow runtime usage of require(), converting `require` into `__require` pub auto_polyfill_require: bool, @@ -292,6 +295,7 @@ pub mod Runtime { dead_code_elimination: true, set_breakpoint_on_first_line: false, trim_unused_imports: false, + remove_unused_declarations: false, auto_polyfill_require: false, replace_exports: ReplaceableExportMap::default(), dont_bundle_twice: false, @@ -354,6 +358,8 @@ pub mod Runtime { // Takes `Wyhash` (NOT `Wyhash11`). pub(crate) fn hash_for_runtime_transpiler(&self, hasher: &mut Wyhash) { debug_assert!(self.runtime_transpiler_cache.is_some()); + // Bun.Transpiler-only (like `replace_exports`), so never set on a cached parse. + debug_assert!(!self.remove_unused_declarations); let bools: [bool; 17] = [ self.top_level_await, diff --git a/src/js_parser/scan/mod.rs b/src/js_parser/scan/mod.rs index 6730bf35e291..8f97903f67bf 100644 --- a/src/js_parser/scan/mod.rs +++ b/src/js_parser/scan/mod.rs @@ -1,3 +1,4 @@ pub mod scan_imports; pub mod scan_side_effects; pub(crate) mod scan_symbols; +pub(crate) mod scan_unused_parts; diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index b34a49c67754..4305bca8afe5 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -265,11 +265,14 @@ impl<'a> ImportScanner<'a> { // e.g. `import 'fancy-stylesheet-thing/style.css';` // This is a breaking change though. We can make it an option with some guardrail // so maybe if it errors, it shows a suggestion "retry without trimming unused imports" + // Eliminated exports and removed declarations still count in ts_use_counts. + let removes_code = p.options.features.remove_unused_declarations + || p.options.features.replace_exports.count() > 0; if (is_typescript_enabled && found_imports && is_unused_in_typescript && !p.options.preserve_unused_imports_ts) - || (!is_typescript_enabled + || ((!is_typescript_enabled || removes_code) && p.options.features.trim_unused_imports && found_imports && st.star_name_loc.is_empty() diff --git a/src/js_parser/scan/scan_unused_parts.rs b/src/js_parser/scan/scan_unused_parts.rs new file mode 100644 index 000000000000..2e3b20515803 --- /dev/null +++ b/src/js_parser/scan/scan_unused_parts.rs @@ -0,0 +1,147 @@ +use crate::RuntimeImports; +use crate::p::P; +use bun_alloc::ArenaVec; +use bun_ast::{self as js_ast, DeclaredSymbol, ImportRecordFlags, Ref, flags}; +use bun_collections::HashMap; +use bun_crash_handler::handle_oom::handle_oom; +use smallvec::SmallVec; + +/// The parts declaring each top-level symbol, keyed by the end of the symbol's `link` chain. +type DeclaringParts = HashMap>; + +impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_ONLY> { + /// Single-file tree shaking of the hoisted (`before`) and remaining top-level parts. + pub(crate) fn remove_unused_parts( + &mut self, + before: &mut ArenaVec<'a, js_ast::Part>, + parts: &mut ArenaVec<'a, js_ast::Part>, + ) { + // The bundler tree shakes in the linker, where cross-file uses are known. + debug_assert!(!self.options.bundle); + + // Code inside a direct eval() can name any top-level declaration. + if self.module_scope().contains_direct_eval { + return; + } + + let arena = self.arena; + let hoisted = before.len(); + let mut all = core::mem::replace(before, ArenaVec::new_in(arena)); + all.append(parts); + + let mut live = bun_alloc::vec_from_iter_in(core::iter::repeat_n(false, all.len()), arena); + let mut worklist = ArenaVec::::new_in(arena); + let mut declaring_parts = DeclaringParts::default(); + + for (i, part) in all.iter().enumerate() { + if !self.part_only_declares_removable_symbols(part) { + live[i] = true; + worklist.push(i as u32); + } + DeclaredSymbol::for_each_top_level_symbol( + &part.declared_symbols, + &mut declaring_parts, + |declaring_parts, declared| { + handle_oom(declaring_parts.get_or_put(self.follow_symbol_links(declared))) + .value_ptr + .push(i as u32); + }, + ); + } + + while let Some(i) = worklist.pop() { + let part = &all[i as usize]; + for &used in part.symbol_uses.keys() { + self.mark_declaring_parts_live(used, &declaring_parts, &mut live, &mut worklist); + } + // Every declaration of a live symbol stays: `export var x = 1; var x = 2;` + DeclaredSymbol::for_each_top_level_symbol( + &part.declared_symbols, + &mut (&mut live, &mut worklist), + |(live, worklist), declared| { + self.mark_declaring_parts_live(declared, &declaring_parts, live, worklist); + }, + ); + } + + for (i, (part, is_live)) in all.into_iter().zip(live.iter()).enumerate() { + if *is_live { + let kept = if i < hoisted { + &mut *before + } else { + &mut *parts + }; + kept.push(part); + continue; + } + // `scan()` and the linker skip unused records. + for &record_index in part.import_record_indices.iter() { + self.import_records.items_mut()[record_index as usize] + .flags + .insert(ImportRecordFlags::IS_UNUSED); + } + self.clear_symbol_usages_from_dead_part(&part); + } + + self.forget_unused_runtime_helpers(); + } + + fn mark_declaring_parts_live( + &self, + symbol: Ref, + declaring_parts: &DeclaringParts, + live: &mut [bool], + worklist: &mut ArenaVec<'a, u32>, + ) { + let Some(declaring) = declaring_parts.get(&self.follow_symbol_links(symbol)) else { + return; + }; + for &i in declaring { + if !live[i as usize] { + live[i as usize] = true; + worklist.push(i); + } + } + } + + /// Imports stay here and get trimmed by use count in the import scanner. + fn part_only_declares_removable_symbols(&self, part: &js_ast::Part) -> bool { + part.can_be_removed_if_unused + && part.stmts.iter().all(|stmt| match &stmt.data { + js_ast::StmtData::SLocal(local) => !local.is_export, + js_ast::StmtData::SFunction(func) => { + !func.func.flags.contains(flags::Function::IsExport) + } + js_ast::StmtData::SClass(class) => !class.is_export, + // Generated next to a declaration, e.g. a TypeScript enum's closure. + js_ast::StmtData::SExpr(expr) => expr.does_not_affect_tree_shaking, + js_ast::StmtData::SEmpty(_) => true, + _ => false, + }) + } + + /// Redeclaring a `var` or function links the earlier symbol to the new one. + fn follow_symbol_links(&self, mut ref_: Ref) -> Ref { + loop { + let symbol = &self.symbols[ref_.inner_index() as usize]; + if !symbol.has_link() { + return ref_; + } + ref_ = symbol.link.get(); + } + } + + /// A `bun:wrap` helper whose callers were all swept would otherwise still be imported. + fn forget_unused_runtime_helpers(&mut self) { + let mut unused: SmallVec<[&'static [u8]; 4]> = SmallVec::new(); + let mut helpers = self.runtime_imports.iter(); + while let Some(helper) = helpers.next() { + if self.symbols[helper.value.inner_index() as usize].use_count_estimate == 0 { + unused.push(RuntimeImports::ALL[helper.key as usize]); + } + } + for name in unused { + self.runtime_imports.put(name, Ref::NONE); + } + } +} diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 8ff6d3293199..7b58c4259e5c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1065,6 +1065,7 @@ impl JSTranspiler { transpiler.options.transform_only = !transpiler.options.allow_runtime; transpiler.options.tree_shaking = config.tree_shaking; + transpiler.options.remove_unused_declarations = config.tree_shaking; transpiler.options.trim_unused_imports = config.trim_unused_imports; transpiler.options.allow_runtime = config.runtime.allow_runtime; transpiler.options.auto_import_jsx = config.runtime.auto_import_jsx; diff --git a/test/bundler/bundler_edgecase.test.ts b/test/bundler/bundler_edgecase.test.ts index c9e970fa08be..f6aad3bd483b 100644 --- a/test/bundler/bundler_edgecase.test.ts +++ b/test/bundler/bundler_edgecase.test.ts @@ -3223,6 +3223,29 @@ describe("bundler", () => { api.expectFile("/out.js").toContain("var arguments = 1;"); }, }); + // Bun.Transpiler's treeShaking drops imports whose users were removed; the bundler shares the + // parser but keeps every imported module for its side effects. + itBundled("edgecase/TSImportsOnlyUsedByRemovedCodeKeepSideEffects", { + files: { + "/entry.ts": /* ts */ ` + import { helperDependency } from './side'; + import { devOnly } from './side2'; + function unusedHelper() { return helperDependency(); } + if (process.env.NODE_ENV !== 'production') { devOnly(); } + console.log('entry'); + `, + "/side.ts": /* ts */ ` + console.log('side'); + export function helperDependency() {} + `, + "/side2.ts": /* ts */ ` + console.log('side2'); + export function devOnly() {} + `, + }, + define: { "process.env.NODE_ENV": '"production"' }, + run: { stdout: "side\nside2\nentry" }, + }); }); for (const backend of ["api", "cli"] as const) { diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 5c05e408c92a..bc2a2b01cc44 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -252,3 +252,39 @@ describe("unterminated string literals in large files", () => { expect(exitCode).toBe(1); }); }); + +// Bun.Transpiler's treeShaking removes helpers nothing uses and the imports they held onto; the +// runtime loader parses with the same parser but must keep loading every imported module. +test("imports only referenced by unused or dead code are still loaded at runtime", async () => { + using dir = tempDir("runtime-keeps-unused-imports", { + "entry.ts": ` + import { helperDependency } from "./side"; + import { devOnly } from "./side2"; + function unusedHelper() { return helperDependency(); } + if (false) { devOnly(); } + console.log("entry"); + `, + "side.ts": ` + console.log("side"); + export function helperDependency() {} + `, + "side2.ts": ` + console.log("side2"); + export function devOnly() {} + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + 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(stdout).toBe("side\nside2\nentry\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 399ffabdbd75..1d99507c46e4 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2013,7 +2013,7 @@ export default class { expect(output.includes("liveFS")).toBe(true); }); - it.todo("supports replacing exports", () => { + it("supports replacing exports", () => { const output = transpiler.transformSync(` import deadFS from 'fs'; import anotherDeadFS from 'fs'; @@ -2045,6 +2045,279 @@ export default class { }); }); + describe("treeShaking", () => { + const shake = (code, options = {}, loader = "tsx") => + new Bun.Transpiler({ loader, treeShaking: true, trimUnusedImports: true, ...options }).transformSync(code); + + // The Next.js-style use case: strip getStaticProps plus everything only it used. + const pageSource = ` + import { readFileSync } from "node:fs"; + import { serverHelper } from "./server-only"; + const TABLE = { a: readFileSync }; + function loadData() { return serverHelper(TABLE); } + export function getStaticProps() { return { props: loadData() }; } + export default function Page(p) { return p.x; } + `; + const pageWithoutServerCode = "export default function Page(p) {\n return p.x;\n}\n"; + + it.each(["tsx", "jsx"])("exports.eliminate also removes what only the export used (%s)", loader => { + expect(shake(pageSource, { exports: { eliminate: ["getStaticProps"] } }, loader)).toBe(pageWithoutServerCode); + }); + + it("exports alone turns it on, for transform() too", async () => { + const transpiler = new Bun.Transpiler({ loader: "tsx", exports: { eliminate: ["getStaticProps"] } }); + expect(await transpiler.transform(pageSource)).toBe(pageWithoutServerCode); + expect(transpiler.transformSync(pageSource)).toBe(pageWithoutServerCode); + }); + + it("scan() only reports the imports that survive", () => { + const transpiler = new Bun.Transpiler({ loader: "tsx", exports: { eliminate: ["getStaticProps"] } }); + expect(transpiler.scan(`import "./for-effect";` + pageSource)).toEqual({ + exports: ["default"], + imports: [{ kind: "import-statement", path: "./for-effect" }], + }); + // require() only produces an import record with allowBunRuntime. + const withRequire = new Bun.Transpiler({ + loader: "tsx", + allowBunRuntime: true, + exports: { eliminate: ["getStaticProps"] }, + }); + expect( + withRequire.scan(` + function loadData() { return [import("./server-db"), require("./server-cjs"), require.resolve("./server-bin")]; } + export function getStaticProps() { return loadData(); } + export default function Page() { return [import("./client-db"), require("./client-cjs")]; } + `), + ).toEqual({ + exports: ["default"], + imports: [ + { kind: "dynamic-import", path: "./client-db" }, + { kind: "require-call", path: "./client-cjs" }, + ], + }); + }); + + it("exports.eliminate of an export clause frees the local it pointed at", () => { + expect( + shake( + `import { db } from "./db"; + var getStaticProps = function () { return db; }; + export { getStaticProps }; + export const keep = 1;`, + { exports: { eliminate: ["getStaticProps"] } }, + ), + ).toBe("export const keep = 1;\n"); + }); + + it.each(["ts", "js"])("exports opts TypeScript imports into the same trimming as JavaScript (%s)", loader => { + const source = 'import { db } from "./db";\nexport function gone() {\n return db;\n}\nexport const keep = 1;\n'; + const options = { exports: { eliminate: ["gone"] }, treeShaking: false, trimUnusedImports: true }; + expect(shake(source, options, loader)).toBe("export const keep = 1;\n"); + expect(shake(source, { ...options, trimUnusedImports: false }, loader)).toBe( + 'import { db } from "./db";\nexport const keep = 1;\n', + ); + }); + + it("removes unused side-effect-free declarations", () => { + expect( + shake( + `import { a } from "a"; + import * as ns from "ns"; + import def, { named } from "mixed"; + const table = { a, ns }; + let [first, ...rest] = [def]; + function helper() { return table; } + class Helper {} + class Sub extends Helper {} + enum Color { Red = 1 } + const x = 1, used = named, y = x; + export { used };`, + ), + ).toBe('import { named } from "mixed";\nconst used = named;\n\nexport { used };\n'); + }); + + it("keeps declarations reachable from exports and top-level statements", () => { + expect( + shake( + `function byNamedExport() {} + function byClause() {} + function byDefault() {} + function byUse() { return byClause; } + function byCall() {} + function unused() { return byUse; } + let counter = 0; + export function inc() { counter++; return byNamedExport; } + export { byUse as use }; + export default byDefault; + byCall();`, + ), + ).toBe( + [ + "function byNamedExport() {}", + "function byClause() {}", + "function byDefault() {}", + "function byUse() {", + " return byClause;", + "}", + "function byCall() {}", + "let counter = 0;", + "export function inc() {", + " counter++;", + " return byNamedExport;", + "}", + "", + "export { byUse as use };", + "export default byDefault;", + "byCall();", + "", + ].join("\n"), + ); + }); + + it("keeps declarations with side effects, and what they import", () => { + expect( + shake( + `import "./for-effect"; + import { connect } from "./db"; + import { build } from "./builder"; + import { lookup } from "./globals"; + const db = connect(); + const built = /* @__PURE__ */ build(); + const fromGlobal = lookup; + const global = someGlobal; + export const keep = 1;`, + ), + ).toBe( + [ + 'import"./for-effect";', + 'import { connect } from "./db";', + "const db = connect();", + "const global = someGlobal;", + "export const keep = 1;", + "", + ].join("\n"), + ); + }); + + it("removes helpers that only reference each other", () => { + const source = ` + import { h } from "./h"; + function a(n) { return n ? a(n - 1) : b(); } + function b() { return a(1) || h(); } + `; + expect(shake(source + "export const keep = 1;")).toBe("export const keep = 1;\n"); + expect(shake(source + "export { b };")).toBe( + [ + 'import { h } from "./h";', + "function a(n) {", + " return n ? a(n - 1) : b();", + "}", + "function b() {", + " return a(1) || h();", + "}", + "", + "export { b };", + "", + ].join("\n"), + ); + }); + + it("treats every declaration of a redeclared symbol as one", () => { + expect(shake("var x = 1;\nexport const y = x;\nvar x;")).toBe("var x = 1;\nexport const y = x;\nvar x;\n"); + expect(shake("var x = 1;\nvar x;\nexport const y = 2;")).toBe("export const y = 2;\n"); + // The exported declaration is live, so the redeclaration that gives it its value must stay. + expect(shake("export var x = 1;\nvar x = 2;")).toBe("export var x = 1;\nvar x = 2;\n"); + expect(shake("export function f() {}\nvar f = 2;")).toBe("export function f() {}\nvar f = 2;\n"); + expect(shake("enum E { A = 1 }\nenum E { B = 2 }\nexport const e = E;", {}, "ts")).toBe( + [ + "var E;", + "((E) => {", + ' E[E["A"] = 1] = "A";', + "})(E ||= {});", + "((E) => {", + ' E[E["B"] = 2] = "B";', + "})(E ||= {});", + "export const e = E;", + "", + ].join("\n"), + ); + }); + + it("removes the JSX runtime import along with the only JSX", () => { + // Pin the dev runtime so the import below does not depend on NODE_ENV. + const options = { autoImportJSX: true, define: { "process.env.NODE_ENV": JSON.stringify("development") } }; + expect(shake("function unused() { return
; }\nexport const keep = 1;", options)).toBe( + "export const keep = 1;\n", + ); + expect(shake("export function Used() { return
; }", options)).toBe( + [ + 'import { jsxDEV as jsxDEV_7x81h0kn } from "react/jsx-dev-runtime";', + "export function Used() {", + ' return jsxDEV_7x81h0kn("div", {}, undefined, false, undefined, this);', + "}", + "", + ].join("\n"), + ); + }); + + it("leaves everything alone when a direct eval can see it", () => { + const source = `import { a } from "a";\nfunction maybeUsed() { return a; }\n`; + expect(shake(source + 'export const r = eval("maybeUsed()");')).toBe( + [ + 'import { a } from "a";', + "function maybeUsed() {", + " return a;", + "}", + 'export const r = eval("maybeUsed()");', + "", + ].join("\n"), + ); + expect(shake(source + 'export const r = (0, eval)("1");')).toBe('export const r = (0, eval)("1");\n'); + }); + + it("only runs when treeShaking is on and dead code elimination is enabled", () => { + const source = 'import { a } from "a";\nfunction unused() {\n return a;\n}\nexport const keep = 1;\n'; + expect(shake(source, { treeShaking: false })).toBe(source); + expect(shake(source, { deadCodeElimination: false })).toBe(source); + expect(shake(source, { trimUnusedImports: false })).toBe('import { a } from "a";\nexport const keep = 1;\n'); + }); + + it("drops the __dirname/__filename and bun:wrap prologue when only removed code needed it", () => { + const helper = name => `function ${name}() { using f = null; return __dirname + __filename; }\n`; + expect(shake(helper("unused") + "export const keep = 1;")).toBe("export const keep = 1;\n"); + const withoutHelperSuffixes = shake(helper("used") + "export const keep = used;").replace( + /(__using|__callDispose)_[a-z0-9]+/g, + "$1", + ); + expect(withoutHelperSuffixes).toBe( + [ + 'var __dirname = "", __filename = "input.tsx";', + 'import { __callDispose as __callDispose, __using as __using } from "bun:wrap";', + "function used() {", + " let __bun_temp_ref_1$ = [];", + " try {", + " const f = __using(__bun_temp_ref_1$, null, 0);", + " return __dirname + __filename;", + " } catch (__bun_temp_ref_2$) {", + " var __bun_temp_ref_3$ = __bun_temp_ref_2$, __bun_temp_ref_4$ = 1;", + " } finally {", + " __callDispose(__bun_temp_ref_1$, __bun_temp_ref_3$, __bun_temp_ref_4$);", + " }", + "}", + "export const keep = used;", + "", + ].join("\n"), + ); + }); + + it("does not let removed code decide the module format", () => { + // Only the removed helper touched `module`, so the file stays ESM and import.meta.main is left alone. + expect(shake("function unused() { return module.exports; }\nconsole.log(import.meta.main);")).toBe( + "console.log(import.meta.main);\n", + ); + }); + }); + const bunTranspiler = new Bun.Transpiler({ loader: "tsx", define: {