Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/runtime/transpiler.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
},

// 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,
Comment thread
robobun marked this conversation as resolved.

// 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
Expand Down
17 changes: 17 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2728,7 +2728,24 @@ declare module "bun" {
eliminate?: string[];
replace?: Record<string, string>;
};
/**
* 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;

Expand Down
4 changes: 4 additions & 0 deletions src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,8 @@ pub struct BundleOptions<'a> {
pub conditions: ESMConditions,
pub tree_shaking: bool,
pub tree_shaking_override: Option<bool>,
/// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 1 addition & 5 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
6 changes: 6 additions & 0 deletions src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
6 changes: 6 additions & 0 deletions src/js_parser/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// Allow runtime usage of require(), converting `require` into `__require`
pub auto_polyfill_require: bool,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/js_parser/scan/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod scan_imports;
pub mod scan_side_effects;
pub(crate) mod scan_symbols;
pub(crate) mod scan_unused_parts;
5 changes: 4 additions & 1 deletion src/js_parser/scan/scan_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
147 changes: 147 additions & 0 deletions src/js_parser/scan/scan_unused_parts.rs
Original file line number Diff line number Diff line change
@@ -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<Ref, SmallVec<[u32; 1]>>;

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::<u32>::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);
}
}
}
1 change: 1 addition & 0 deletions src/runtime/api/JSTranspiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions test/bundler/bundler_edgecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions test/bundler/transpiler/runtime-transpiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading