From 1cd335a564313d475fe188598686ee23daa4a952 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Fri, 17 Oct 2025 15:37:45 +1100 Subject: [PATCH 01/22] Implement new `dead_returns_remover` transformer Implement new `unused_bindings_remover` transformer Hook up transformers using env to enable/disable --- .../js/core/src/dead_returns_remover.rs | 359 ++++++++++++ packages/transformers/js/core/src/lib.rs | 16 + .../js/core/src/unused_bindings_remover.rs | 525 ++++++++++++++++++ packages/transformers/js/src/JSTransformer.ts | 12 + 4 files changed, 912 insertions(+) create mode 100644 packages/transformers/js/core/src/dead_returns_remover.rs create mode 100644 packages/transformers/js/core/src/unused_bindings_remover.rs diff --git a/packages/transformers/js/core/src/dead_returns_remover.rs b/packages/transformers/js/core/src/dead_returns_remover.rs new file mode 100644 index 000000000..89c9bbb66 --- /dev/null +++ b/packages/transformers/js/core/src/dead_returns_remover.rs @@ -0,0 +1,359 @@ +use swc_core::ecma::ast::*; +use swc_core::ecma::visit::{VisitMut, VisitMutWith}; + +/// Transformer that removes unreachable statements after return statements. +/// +/// In functions with multiple return statements, only the first return is reachable. +/// This transform removes all statements that appear after the first return statement +/// in a block, as they can never be executed. +/// +/// # Example +/// +/// Input: +/// ```js +/// function foo() { +/// console.log('before'); +/// return 1; +/// console.log('unreachable'); +/// return 2; +/// } +/// ``` +/// +/// Output: +/// ```js +/// function foo() { +/// console.log('before'); +/// return 1; +/// } +/// ``` +/// +/// # Nested Blocks +/// +/// The transformer also handles nested block statements: +/// +/// ```js +/// function bar() { +/// if (condition) { +/// return 1; +/// console.log('dead'); +/// } +/// return 2; +/// console.log('also dead'); +/// } +/// ``` +/// +/// Output: +/// ```js +/// function bar() { +/// if (condition) { +/// return 1; +/// } +/// return 2; +/// } +/// ``` +pub struct DeadReturnsRemover; + +impl DeadReturnsRemover { + pub fn new() -> Self { + Self + } + + fn remove_dead_returns(stmts: &mut Vec) { + if let Some(return_idx) = stmts + .iter() + .position(|stmt| matches!(stmt, Stmt::Return(_))) + { + stmts.truncate(return_idx + 1); + } + } +} + +impl VisitMut for DeadReturnsRemover { + fn visit_mut_function(&mut self, node: &mut Function) { + node.visit_mut_children_with(self); + if let Some(body) = &mut node.body { + Self::remove_dead_returns(&mut body.stmts); + } + } + + fn visit_mut_arrow_expr(&mut self, node: &mut ArrowExpr) { + node.visit_mut_children_with(self); + if let BlockStmtOrExpr::BlockStmt(block) = &mut *node.body { + Self::remove_dead_returns(&mut block.stmts); + } + } + + fn visit_mut_block_stmt(&mut self, node: &mut BlockStmt) { + node.visit_mut_children_with(self); + Self::remove_dead_returns(&mut node.stmts); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atlaspack_swc_runner::test_utils::{RunTestContext, RunVisitResult, run_test_visit}; + use indoc::indoc; + + #[test] + fn test_removes_statements_after_return() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + console.log('before'); + return 1; + console.log('unreachable'); + return 2; + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + console.log('before'); + return 1; + } + "#} + ); + } + + #[test] + fn test_removes_dead_returns_in_nested_blocks() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function bar() { + if (condition) { + doSomething(); + return 1; + console.log('dead'); + return 999; + } + return 2; + console.log('also dead'); + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function bar() { + if (condition) { + doSomething(); + return 1; + } + return 2; + } + "#} + ); + } + + #[test] + fn test_works_with_arrow_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const fn = () => { + console.log('start'); + return 42; + console.log('unreachable'); + }; + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const fn = ()=>{ + console.log('start'); + return 42; + }; + "#} + ); + } + + #[test] + fn test_preserves_statements_before_return() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function baz() { + const x = 1; + const y = 2; + console.log(x + y); + return x + y; + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function baz() { + const x = 1; + const y = 2; + console.log(x + y); + return x + y; + } + "#} + ); + } + + #[test] + fn test_handles_function_with_no_return() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function noReturn() { + console.log('line 1'); + console.log('line 2'); + console.log('line 3'); + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function noReturn() { + console.log('line 1'); + console.log('line 2'); + console.log('line 3'); + } + "#} + ); + } + + #[test] + fn test_handles_early_return_in_conditional() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function earlyReturn(x) { + if (x < 0) { + console.log('negative'); + return -1; + console.log('after return in if'); + } + console.log('positive or zero'); + return x; + console.log('after final return'); + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function earlyReturn(x) { + if (x < 0) { + console.log('negative'); + return -1; + } + console.log('positive or zero'); + return x; + } + "#} + ); + } + + #[test] + fn test_handles_nested_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function outer() { + function inner() { + return 1; + console.log('dead in inner'); + } + return inner(); + console.log('dead in outer'); + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function outer() { + function inner() { + return 1; + } + return inner(); + } + "#} + ); + } + + #[test] + fn test_handles_multiple_nested_blocks() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function complex() { + if (a) { + { + return 1; + console.log('dead 1'); + } + } + return 2; + console.log('dead 3'); + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function complex() { + if (a) { + { + return 1; + } + } + return 2; + } + "#} + ); + } + + #[test] + fn test_empty_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function empty() {} + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function empty() {} + "#} + ); + } + + #[test] + fn test_arrow_function_with_expression_body() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const fn = () => 42; + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const fn = ()=>42; + "#} + ); + } +} diff --git a/packages/transformers/js/core/src/lib.rs b/packages/transformers/js/core/src/lib.rs index 5679bc40b..8dcdef741 100644 --- a/packages/transformers/js/core/src/lib.rs +++ b/packages/transformers/js/core/src/lib.rs @@ -1,6 +1,7 @@ pub mod add_display_name; mod collect; mod constant_module; +mod dead_returns_remover; mod dependency_collector; mod env_replacer; mod esm_export_classifier; @@ -14,6 +15,7 @@ mod magic_comments; mod node_replacer; pub mod test_utils; mod typeof_replacer; +mod unused_bindings_remover; mod utils; use std::collections::HashMap; @@ -33,6 +35,7 @@ use collect::Collect; pub use collect::CollectImportedSymbol; use collect::CollectResult; use constant_module::ConstantModule; +use dead_returns_remover::DeadReturnsRemover; pub use dependency_collector::DependencyDescriptor; pub use dependency_collector::dependency_collector; use env_replacer::*; @@ -95,6 +98,7 @@ use swc_core::ecma::visit::VisitMutWith; use swc_core::ecma::visit::VisitWith; use swc_core::ecma::visit::visit_mut_pass; use typeof_replacer::*; +use unused_bindings_remover::UnusedBindingsRemover; use utils::CodeHighlight; pub use utils::Diagnostic; use utils::DiagnosticSeverity; @@ -150,6 +154,8 @@ pub struct Config { pub exports_rebinding_optimisation: bool, pub enable_global_this_aliaser: bool, pub enable_lazy_loading_transformer: bool, + pub enable_dead_returns_remover: bool, + pub enable_unused_bindings_remover: bool, } #[derive(Serialize, Debug, Default)] @@ -434,6 +440,16 @@ pub fn transform( // don't include dependencies inside conditionals that are always false. expr_simplifier(unresolved_mark, Default::default()), dead_branch_remover(unresolved_mark), + // Remove unreachable statements after return statements + Optional::new( + visit_mut_pass(DeadReturnsRemover::new()), + config.enable_dead_returns_remover + ), + // Remove unused variable bindings + Optional::new( + visit_mut_pass(UnusedBindingsRemover::new()), + config.enable_unused_bindings_remover + ), // Inline Node fs.readFileSync calls Optional::new( visit_mut_pass(inline_fs( diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs new file mode 100644 index 000000000..537a4d722 --- /dev/null +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -0,0 +1,525 @@ +use std::collections::{HashMap, HashSet}; +use swc_core::ecma::ast::*; +use swc_core::ecma::visit::{VisitMut, VisitMutWith, VisitWith}; + +/// Transformer that removes unused variable bindings. +/// +/// This transform removes variable declarations that are never referenced, +/// helping to clean up dead code. It handles: +/// - Simple variable declarations +/// - Object destructuring patterns +/// - Array destructuring patterns +/// - Special cases like `di()` calls and exports +/// +/// # Example +/// +/// Input: +/// ```js +/// const unused = 1; +/// const used = 2; +/// console.log(used); +/// ``` +/// +/// Output: +/// ```js +/// const used = 2; +/// console.log(used); +/// ``` +/// +/// # Destructuring +/// +/// For destructuring patterns, only unused bindings are removed: +/// +/// ```js +/// const { a, b, c: { d, e } } = obj; +/// console.log(a, d); +/// ``` +/// +/// Output: +/// ```js +/// const { a, c: { d, } } = obj; +/// console.log(a, d); +/// ``` +pub struct UnusedBindingsRemover { + used_bindings: HashSet, + declared_bindings: HashMap, +} + +impl UnusedBindingsRemover { + pub fn new() -> Self { + Self { + used_bindings: HashSet::new(), + declared_bindings: HashMap::new(), + } + } + + fn is_special_ident(name: &str) -> bool { + matches!(name, "di" | "jsx" | "React") + } + + fn should_keep_binding(&self, id: &Id, is_exported: bool) -> bool { + self.used_bindings.contains(id) || Self::is_special_ident(&id.0) || is_exported + } + + fn is_pattern_empty(&self, pat: &Pat) -> bool { + match pat { + Pat::Ident(ident) => !self.used_bindings.contains(&ident.id.to_id()), + Pat::Object(obj) => obj.props.is_empty(), + Pat::Array(arr) => arr.elems.iter().all(Option::is_none), + _ => false, + } + } + + fn remove_from_pat(&self, pat: &mut Pat) { + match pat { + Pat::Object(obj) => { + // Recursively process nested patterns + for prop in &mut obj.props { + match prop { + ObjectPatProp::KeyValue(kv) => self.remove_from_pat(&mut kv.value), + ObjectPatProp::Rest(rest) => self.remove_from_pat(&mut rest.arg), + _ => {} + } + } + + // Remove unused props + obj.props.retain(|prop| match prop { + ObjectPatProp::KeyValue(kv) => !self.is_pattern_empty(&kv.value), + ObjectPatProp::Assign(assign) => self.used_bindings.contains(&assign.key.to_id()), + ObjectPatProp::Rest(rest) => !self.is_pattern_empty(&rest.arg), + }); + } + Pat::Array(arr) => { + // Recursively process nested patterns + for elem in arr.elems.iter_mut().flatten() { + self.remove_from_pat(elem); + } + + // Remove unused elements + for elem in &mut arr.elems { + if matches!(elem, Some(p) if self.is_pattern_empty(p)) { + *elem = None; + } + } + } + _ => {} + } + } +} + +struct BindingCollector<'a> { + used_bindings: &'a mut HashSet, + declared_bindings: &'a HashMap, +} + +impl BindingCollector<'_> { + fn mark_used(&mut self, id: Id) { + if self.declared_bindings.contains_key(&id) { + self.used_bindings.insert(id); + } + } +} + +impl swc_core::ecma::visit::Visit for BindingCollector<'_> { + // Visit expressions to find identifier references + fn visit_expr(&mut self, expr: &Expr) { + if let Expr::Ident(ident) = expr { + self.mark_used(ident.to_id()); + } + expr.visit_children_with(self); + } + + // Visit property shorthand: { foo } is a reference to foo + fn visit_prop(&mut self, prop: &Prop) { + if let Prop::Shorthand(ident) = prop { + self.mark_used(ident.to_id()); + } + prop.visit_children_with(self); + } + + // Mark exported identifiers as used + fn visit_export_named_specifier(&mut self, spec: &ExportNamedSpecifier) { + if let ModuleExportName::Ident(ident) = &spec.orig { + self.used_bindings.insert(ident.to_id()); + } + } + + // Don't visit patterns (they're declarations, not references) + fn visit_pat(&mut self, _pat: &Pat) { + // Skip - patterns are declarations not usages + } +} + +impl VisitMut for UnusedBindingsRemover { + fn visit_mut_module(&mut self, module: &mut Module) { + // Collect declarations + for item in &module.body { + self.collect_declarations_from_module_item(item); + } + + // Collect usages + let mut collector = BindingCollector { + used_bindings: &mut self.used_bindings, + declared_bindings: &self.declared_bindings, + }; + module.visit_with(&mut collector); + + // Remove unused bindings + module.visit_mut_children_with(self); + + // Clean up empty declarations + module.body.retain( + |item| !matches!(item, ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) if var.decls.is_empty()), + ); + } + + fn visit_mut_var_declarator(&mut self, node: &mut VarDeclarator) { + node.visit_mut_children_with(self); + self.remove_from_pat(&mut node.name); + } + + fn visit_mut_var_decl(&mut self, node: &mut VarDecl) { + node.visit_mut_children_with(self); + + node.decls.retain(|decl| match &decl.name { + Pat::Object(obj) if obj.props.is_empty() => false, + Pat::Array(arr) if arr.elems.iter().all(Option::is_none) => false, + Pat::Ident(ident) => { + let id = ident.id.to_id(); + self + .declared_bindings + .get(&id) + .is_none_or(|&is_exported| self.should_keep_binding(&id, is_exported)) + } + _ => true, + }); + } +} + +impl UnusedBindingsRemover { + fn collect_declarations_from_module_item(&mut self, item: &ModuleItem) { + let (decl, is_exported) = match item { + ModuleItem::Stmt(Stmt::Decl(decl)) => (decl, false), + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => (&export.decl, true), + _ => return, + }; + + if let Decl::Var(var) = decl { + for declarator in &var.decls { + self.collect_declarations_from_pat(&declarator.name, is_exported); + } + } + } + + fn collect_declarations_from_pat(&mut self, pat: &Pat, is_exported: bool) { + match pat { + Pat::Ident(ident) => { + self.declared_bindings.insert(ident.id.to_id(), is_exported); + } + Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + self.collect_declarations_from_pat(elem, is_exported); + } + } + Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ObjectPatProp::KeyValue(kv) => { + self.collect_declarations_from_pat(&kv.value, is_exported); + } + ObjectPatProp::Assign(assign) => { + self + .declared_bindings + .insert(assign.key.to_id(), is_exported); + } + ObjectPatProp::Rest(rest) => { + self.collect_declarations_from_pat(&rest.arg, is_exported); + } + } + } + } + Pat::Rest(rest) => self.collect_declarations_from_pat(&rest.arg, is_exported), + Pat::Assign(assign) => self.collect_declarations_from_pat(&assign.left, is_exported), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atlaspack_swc_runner::test_utils::{RunTestContext, RunVisitResult, run_test_visit}; + use indoc::indoc; + + #[test] + fn test_removes_unused_variable() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const unused = 1; + const used = 2; + console.log(used); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const used = 2; + console.log(used); + "#} + ); + } + + #[test] + fn test_keeps_used_variables() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const a = 1; + const b = 2; + console.log(a, b); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const a = 1; + const b = 2; + console.log(a, b); + "#} + ); + } + + #[test] + fn test_removes_unused_from_object_destructuring() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, b, c } = obj; + console.log(a, c); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { a, c } = obj; + console.log(a, c); + "#} + ); + } + + #[test] + fn test_removes_unused_from_array_destructuring() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, b, c] = arr; + console.log(a, c); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [a, , c] = arr; + console.log(a, c); + "#} + ); + } + + #[test] + fn test_removes_entire_declaration_if_all_unused() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, b } = obj; + console.log(other); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + console.log(other); + "#} + ); + } + + #[test] + fn test_keeps_react_identifier() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const React = require('react'); + const unused = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const React = require('react'); + "#} + ); + } + + #[test] + fn test_keeps_di_identifier() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const di = something; + const unused = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const di = something; + "#} + ); + } + + #[test] + fn test_keeps_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + export const exported = 1; + const notExported = 2; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + export const exported = 1; + "#} + ); + } + + #[test] + fn test_handles_nested_destructuring() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, b: { c, d } } = obj; + console.log(a, c); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { a, b: { c } } = obj; + console.log(a, c); + "#} + ); + } + + #[test] + fn test_handles_deeply_nested_destructuring() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a: { b: { c, d }, e }, f } = obj; + console.log(c, f); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { a: { b: { c } }, f } = obj; + console.log(c, f); + "#} + ); + } + + #[test] + fn test_handles_nested_array_destructuring() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, [b, c], d] = arr; + console.log(a, c); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + // Note: The output has a trailing comma which is valid JS + assert_eq!( + output_code, + indoc! {r#" + const [a, [, c], ] = arr; + console.log(a, c); + "#} + ); + } + + #[test] + fn test_removes_multiple_consecutive_array_elements() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, b, c, d, e] = arr; + console.log(a, e); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + // Multiple holes in array destructuring is valid JS: [a, , , , e] + assert_eq!( + output_code, + indoc! {r#" + const [a, , , , e] = arr; + console.log(a, e); + "#} + ); + } + + #[test] + fn test_removes_entire_nested_array_if_empty() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, [b, c], d] = arr; + console.log(a, d); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + // The nested array [b, c] becomes empty and should be removed entirely + assert_eq!( + output_code, + indoc! {r#" + const [a, , d] = arr; + console.log(a, d); + "#} + ); + } + + #[test] + fn test_keeps_variables_used_in_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const x = 1; + function foo() { + return x; + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const x = 1; + function foo() { + return x; + } + "#} + ); + } +} diff --git a/packages/transformers/js/src/JSTransformer.ts b/packages/transformers/js/src/JSTransformer.ts index 314c39733..7595cf689 100644 --- a/packages/transformers/js/src/JSTransformer.ts +++ b/packages/transformers/js/src/JSTransformer.ts @@ -309,6 +309,12 @@ export default new Transformer({ let enableLazyLoadingTransformer = Boolean( options.env.NATIVE_LAZY_LOADING_TRANSFORMER, ); + let enableDeadReturnsRemover = Boolean( + options.env.NATIVE_DEAD_RETURNS_REMOVER, + ); + let enableUnusedBindingsRemover = Boolean( + options.env.NATIVE_UNUSED_BINDINGS_REMOVER, + ); if (conf && conf.contents) { validateSchema.diagnostic( @@ -355,6 +361,8 @@ export default new Transformer({ magicComments, enableGlobalThisAliaser, enableLazyLoadingTransformer, + enableDeadReturnsRemover, + enableUnusedBindingsRemover, }; }, async transform({asset, config, options, logger}) { @@ -537,6 +545,10 @@ export default new Transformer({ enable_lazy_loading_transformer: Boolean( config.enableLazyLoadingTransformer, ), + enable_dead_returns_remover: Boolean(config.enableDeadReturnsRemover), + enable_unused_bindings_remover: Boolean( + config.enableUnusedBindingsRemover, + ), callMacro: asset.isSource ? async (err: any, src: any, exportName: any, args: any, loc: any) => { let mod; From 3106bf0740053d4219b43f433158681f2e9114b2 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 20 Oct 2025 13:10:17 +1100 Subject: [PATCH 02/22] Refactor `dead_returns_remover` to preserve non-return statements --- .../js/core/src/dead_returns_remover.rs | 122 ++++++++++++++++-- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/transformers/js/core/src/dead_returns_remover.rs b/packages/transformers/js/core/src/dead_returns_remover.rs index 89c9bbb66..5e70ae79a 100644 --- a/packages/transformers/js/core/src/dead_returns_remover.rs +++ b/packages/transformers/js/core/src/dead_returns_remover.rs @@ -1,11 +1,11 @@ use swc_core::ecma::ast::*; use swc_core::ecma::visit::{VisitMut, VisitMutWith}; -/// Transformer that removes unreachable statements after return statements. +/// Transformer that removes unreachable return statements after the first return. /// /// In functions with multiple return statements, only the first return is reachable. -/// This transform removes all statements that appear after the first return statement -/// in a block, as they can never be executed. +/// This transform removes only the return statements that appear after the first return +/// statement in a block, preserving other statements. /// /// # Example /// @@ -24,6 +24,7 @@ use swc_core::ecma::visit::{VisitMut, VisitMutWith}; /// function foo() { /// console.log('before'); /// return 1; +/// console.log('unreachable'); /// } /// ``` /// @@ -47,8 +48,10 @@ use swc_core::ecma::visit::{VisitMut, VisitMutWith}; /// function bar() { /// if (condition) { /// return 1; +/// console.log('dead'); /// } /// return 2; +/// console.log('also dead'); /// } /// ``` pub struct DeadReturnsRemover; @@ -58,12 +61,22 @@ impl DeadReturnsRemover { Self } + fn has_return(stmt: &Stmt) -> bool { + match stmt { + Stmt::Return(_) => true, + Stmt::Block(block) => block.stmts.iter().any(Self::has_return), + _ => false, + } + } + fn remove_dead_returns(stmts: &mut Vec) { - if let Some(return_idx) = stmts - .iter() - .position(|stmt| matches!(stmt, Stmt::Return(_))) - { - stmts.truncate(return_idx + 1); + if let Some(first_return_idx) = stmts.iter().position(Self::has_return) { + let mut idx = 0; + stmts.retain(|stmt| { + let current_idx = idx; + idx += 1; + current_idx <= first_return_idx || !matches!(stmt, Stmt::Return(_)) + }); } } } @@ -115,6 +128,7 @@ mod tests { function foo() { console.log('before'); return 1; + console.log('unreachable'); } "#} ); @@ -145,8 +159,10 @@ mod tests { if (condition) { doSomething(); return 1; + console.log('dead'); } return 2; + console.log('also dead'); } "#} ); @@ -171,11 +187,38 @@ mod tests { const fn = ()=>{ console.log('start'); return 42; + console.log('unreachable'); }; "#} ); } + // Test does not currently succeed due to SWC codegen removing comments after return statements. + #[test] + fn test_retains_comments_after_return() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + console.log('before'); + return 1; + // test comment + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + console.log('before'); + return 1; + // test comment + } + "#} + ); + } + #[test] fn test_preserves_statements_before_return() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -253,9 +296,11 @@ mod tests { if (x < 0) { console.log('negative'); return -1; + console.log('after return in if'); } console.log('positive or zero'); return x; + console.log('after final return'); } "#} ); @@ -283,8 +328,10 @@ mod tests { function outer() { function inner() { return 1; + console.log('dead in inner'); } return inner(); + console.log('dead in outer'); } "#} ); @@ -315,14 +362,45 @@ mod tests { if (a) { { return 1; + console.log('dead 1'); } } return 2; + console.log('dead 3'); } "#} ); } + #[test] + fn test_retains_nested_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function complex() { + var variable = nested(); + return variable; + function nested() { + return 1; + } + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function complex() { + var variable = nested(); + return variable; + function nested() { + return 1; + } + } + "#}, + ); + } + #[test] fn test_empty_function() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -356,4 +434,32 @@ mod tests { "#} ); } + + #[test] + fn test_return_in_non_conditional_block() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + { + return 1; + } + console.log("foo"); + return 2; + } + "#}, + |_: RunTestContext| DeadReturnsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + { + return 1; + } + console.log("foo"); + } + "#} + ); + } } From b2c29a53de8c45191f0ed817cab3d25ba946497c Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Tue, 21 Oct 2025 12:10:15 +1100 Subject: [PATCH 03/22] Update trailing comments test --- .../transformers/js/core/src/dead_returns_remover.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/transformers/js/core/src/dead_returns_remover.rs b/packages/transformers/js/core/src/dead_returns_remover.rs index 5e70ae79a..08f03e751 100644 --- a/packages/transformers/js/core/src/dead_returns_remover.rs +++ b/packages/transformers/js/core/src/dead_returns_remover.rs @@ -194,14 +194,17 @@ mod tests { } // Test does not currently succeed due to SWC codegen removing comments after return statements. + #[ignore] #[test] fn test_retains_comments_after_return() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" function foo() { + // first comment console.log('before'); + // second comment return 1; - // test comment + // trailing comment } "#}, |_: RunTestContext| DeadReturnsRemover::new(), @@ -211,9 +214,11 @@ mod tests { output_code, indoc! {r#" function foo() { + // first comment console.log('before'); + // second comment return 1; - // test comment + // trailing comment } "#} ); From faf36f8e5bb3afdf6b1edc942c67a9515dd14279 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Wed, 22 Oct 2025 12:45:27 +1100 Subject: [PATCH 04/22] Enhance UnusedBindingsRemover to correctly handle various export scenarios and preserve bindings in tests --- .../js/core/src/unused_bindings_remover.rs | 215 +++++++++++++++++- 1 file changed, 214 insertions(+), 1 deletion(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 537a4d722..9bbff1871 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -137,19 +137,94 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { prop.visit_children_with(self); } - // Mark exported identifiers as used + // Mark exported identifiers as used (export { foo }) fn visit_export_named_specifier(&mut self, spec: &ExportNamedSpecifier) { if let ModuleExportName::Ident(ident) = &spec.orig { self.used_bindings.insert(ident.to_id()); } } + // Mark exported declarations as used (export const foo = ...) + fn visit_export_decl(&mut self, export: &ExportDecl) { + if let Decl::Var(var) = &export.decl { + for declarator in &var.decls { + self.mark_exported_pat(&declarator.name); + } + } + export.visit_children_with(self); + } + + // Mark default exports as used (export default foo) + fn visit_export_default_expr(&mut self, export: &ExportDefaultExpr) { + if let Expr::Ident(ident) = &*export.expr { + self.used_bindings.insert(ident.to_id()); + } + export.visit_children_with(self); + } + + // Mark default export declarations as used (export default function foo() {}) + fn visit_export_default_decl(&mut self, export: &ExportDefaultDecl) { + match &export.decl { + DefaultDecl::Fn(fn_expr) => { + if let Some(ident) = &fn_expr.ident { + self.used_bindings.insert(ident.to_id()); + } + } + DefaultDecl::Class(class_expr) => { + if let Some(ident) = &class_expr.ident { + self.used_bindings.insert(ident.to_id()); + } + } + DefaultDecl::TsInterfaceDecl(_) => {} + } + export.visit_children_with(self); + } + + // Mark assignments to module.exports or exports.* as keeping those bindings alive + fn visit_assign_expr(&mut self, assign: &AssignExpr) { + // Always traverse children - this handles all expressions + assign.visit_children_with(self); + } + // Don't visit patterns (they're declarations, not references) fn visit_pat(&mut self, _pat: &Pat) { // Skip - patterns are declarations not usages } } +impl BindingCollector<'_> { + fn mark_exported_pat(&mut self, pat: &Pat) { + match pat { + Pat::Ident(ident) => { + self.used_bindings.insert(ident.id.to_id()); + } + Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + self.mark_exported_pat(elem); + } + } + Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ObjectPatProp::KeyValue(kv) => { + self.mark_exported_pat(&kv.value); + } + ObjectPatProp::Assign(assign) => { + self.used_bindings.insert(assign.key.to_id()); + } + ObjectPatProp::Rest(rest) => { + self.mark_exported_pat(&rest.arg); + } + } + } + } + Pat::Rest(rest) => self.mark_exported_pat(&rest.arg), + Pat::Assign(assign) => self.mark_exported_pat(&assign.left), + _ => {} + } + } +} + impl VisitMut for UnusedBindingsRemover { fn visit_mut_module(&mut self, module: &mut Module) { // Collect declarations @@ -402,6 +477,24 @@ mod tests { ); } + #[test] + fn test_keeps_structured_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + export const { a, b } = obj; + const { unused } = obj; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + export const { a, b } = obj; + "#} + ); + } + #[test] fn test_handles_nested_destructuring() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -522,4 +615,124 @@ mod tests { "#} ); } + + #[test] + fn test_keeps_default_export() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const obj = {}; + const unused = 1; + export default obj; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const obj = {}; + export default obj; + "#} + ); + } + + #[test] + fn test_keeps_default_function_export() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const unused = 1; + export default function foo() {} + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + export default function foo() {} + "#} + ); + } + + #[test] + fn test_preserves_reexports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + export { default as LottiePlayer } from 'lottie-web'; + const unused = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + export { default as LottiePlayer } from 'lottie-web'; + "#} + ); + } + + #[test] + fn test_keeps_object_used_in_member_expressions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const lottie = {}; + lottie.play = function() {}; + lottie.pause = function() {}; + const unused = 1; + export default lottie; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const lottie = {}; + lottie.play = function() {}; + lottie.pause = function() {}; + export default lottie; + "#} + ); + } + + #[test] + fn test_keeps_cjs_module_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const foo = 1; + const unused = 2; + module.exports = foo; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const foo = 1; + module.exports = foo; + "#} + ); + } + + #[test] + fn test_keeps_cjs_property_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const bar = 1; + const unused = 2; + exports.default = bar; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const bar = 1; + exports.default = bar; + "#} + ); + } } From 41ec50ade3483b0c7e62cb4aa07b0120a7a63449 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Wed, 22 Oct 2025 14:27:26 +1100 Subject: [PATCH 05/22] Optimize assignment expression handling and add test for IIFE pattern --- .../js/core/src/unused_bindings_remover.rs | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 9bbff1871..f60c5db30 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -180,10 +180,11 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { export.visit_children_with(self); } - // Mark assignments to module.exports or exports.* as keeping those bindings alive + // Handle assignment expressions - only visit the right side (value being assigned) fn visit_assign_expr(&mut self, assign: &AssignExpr) { - // Always traverse children - this handles all expressions - assign.visit_children_with(self); + // Only visit right side - left side is assignment target, not a usage + assign.right.visit_with(self); + // Don't visit left side or call visit_children_with to avoid visiting assignment target } // Don't visit patterns (they're declarations, not references) @@ -735,4 +736,32 @@ mod tests { "#} ); } + + #[test] + fn test_keeps_factory_in_iife_pattern() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + (function (global, factory) { + module.exports = factory(); + })(this, (function () { + const lottie = {}; + return lottie; + })); + const unused = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + (function(global, factory) { + module.exports = factory(); + })(this, function() { + const lottie = {}; + return lottie; + }); + "#} + ); + } } From 6d46ac769e6d0c1981b718c5b92079a17f16dcf4 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Thu, 23 Oct 2025 12:01:27 +1100 Subject: [PATCH 06/22] Improve `unused_bindings_remover` multi-pass Refactor and clean up `unused_bindings_remover` Add a lot more tests --- .../js/core/src/unused_bindings_remover.rs | 648 ++++++++++++++---- 1 file changed, 501 insertions(+), 147 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index f60c5db30..9406ed19e 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -2,10 +2,41 @@ use std::collections::{HashMap, HashSet}; use swc_core::ecma::ast::*; use swc_core::ecma::visit::{VisitMut, VisitMutWith, VisitWith}; +/// Helper function to recursively collect binding identifiers from patterns. +/// Calls the provided closure for each binding found, passing the identifier and export status. +fn collect_bindings_from_pat(pat: &Pat, is_exported: bool, f: &mut F) +where + F: FnMut(Id, bool), +{ + match pat { + Pat::Ident(ident) => f(ident.id.to_id(), is_exported), + Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + collect_bindings_from_pat(elem, is_exported, f); + } + } + Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ObjectPatProp::KeyValue(kv) => collect_bindings_from_pat(&kv.value, is_exported, f), + ObjectPatProp::Assign(assign) => f(assign.key.to_id(), is_exported), + ObjectPatProp::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), + } + } + } + Pat::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), + Pat::Assign(assign) => collect_bindings_from_pat(&assign.left, is_exported, f), + _ => {} + } +} + /// Transformer that removes unused variable bindings. /// /// This transform removes variable declarations that are never referenced, -/// helping to clean up dead code. It handles: +/// helping to clean up dead code. It uses a multi-pass algorithm to handle +/// cascading dependencies (e.g., `const a = 1; const b = a;` where neither is used). +/// +/// It handles: /// - Simple variable declarations /// - Object destructuring patterns /// - Array destructuring patterns @@ -40,6 +71,7 @@ use swc_core::ecma::visit::{VisitMut, VisitMutWith, VisitWith}; /// const { a, c: { d, } } = obj; /// console.log(a, d); /// ``` +#[derive(Default)] pub struct UnusedBindingsRemover { used_bindings: HashSet, declared_bindings: HashMap, @@ -47,10 +79,7 @@ pub struct UnusedBindingsRemover { impl UnusedBindingsRemover { pub fn new() -> Self { - Self { - used_bindings: HashSet::new(), - declared_bindings: HashMap::new(), - } + Self::default() } fn is_special_ident(name: &str) -> bool { @@ -63,13 +92,88 @@ impl UnusedBindingsRemover { fn is_pattern_empty(&self, pat: &Pat) -> bool { match pat { - Pat::Ident(ident) => !self.used_bindings.contains(&ident.id.to_id()), + Pat::Ident(ident) => { + let id = ident.id.to_id(); + !self.used_bindings.contains(&id) && !Self::is_special_ident(&id.0) + } Pat::Object(obj) => obj.props.is_empty(), Pat::Array(arr) => arr.elems.iter().all(Option::is_none), _ => false, } } + fn cleanup_module_items(&self, items: &mut Vec) { + items.retain(|item| match item { + ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) => !var.decls.is_empty(), + ModuleItem::ModuleDecl(ModuleDecl::Import(import)) => !import.specifiers.is_empty(), + _ => true, + }); + } + + fn cleanup_empty_var_decls(&self, stmts: &mut Vec) { + stmts.retain(|stmt| !matches!(stmt, Stmt::Decl(Decl::Var(var)) if var.decls.is_empty())); + } + + fn should_keep_declarator(&self, decl: &VarDeclarator) -> bool { + match &decl.name { + Pat::Ident(ident) => { + let id = ident.id.to_id(); + self + .declared_bindings + .get(&id) + .is_none_or(|&is_exported| self.should_keep_binding(&id, is_exported)) + } + _ => true, + } + } + + fn should_keep_import_spec(&self, spec: &ImportSpecifier) -> bool { + let id = match spec { + ImportSpecifier::Named(named) => &named.local, + ImportSpecifier::Default(default) => &default.local, + ImportSpecifier::Namespace(ns) => &ns.local, + }; + self.should_keep_binding(&id.to_id(), false) + } + + /// Runs multiple passes of unused binding elimination until no more progress can be made. + /// Each pass collects declarations, collects usages, then removes unused bindings. + fn run_elimination_passes(&mut self, module: &mut Module) { + loop { + self.declared_bindings.clear(); + self.used_bindings.clear(); + + // Collect declarations + module.visit_with(&mut DeclarationCollector::new(&mut self.declared_bindings)); + + if self.declared_bindings.is_empty() { + break; + } + + let declarations_before = self.declared_bindings.len(); + + // Collect usages + module.visit_with(&mut BindingCollector::new( + &mut self.used_bindings, + &self.declared_bindings, + )); + + // Remove unused bindings + module.visit_mut_children_with(self); + + // Clean up empty declarations and imports + self.cleanup_module_items(&mut module.body); + + // Check if we made progress + self.declared_bindings.clear(); + module.visit_with(&mut DeclarationCollector::new(&mut self.declared_bindings)); + + if self.declared_bindings.len() == declarations_before { + break; + } + } + } + fn remove_from_pat(&self, pat: &mut Pat) { match pat { Pat::Object(obj) => { @@ -107,13 +211,75 @@ impl UnusedBindingsRemover { } } +/// Visitor that collects all variable and import declarations. +struct DeclarationCollector<'a> { + declared_bindings: &'a mut HashMap, +} + +impl<'a> DeclarationCollector<'a> { + fn new(declared_bindings: &'a mut HashMap) -> Self { + Self { declared_bindings } + } +} + +impl swc_core::ecma::visit::Visit for DeclarationCollector<'_> { + // Collect all variable declarations (var, let, const) - NOT function/class declarations + fn visit_var_decl(&mut self, var: &VarDecl) { + for declarator in &var.decls { + self.collect_bindings_from_pat(&declarator.name, false); + } + // Continue visiting to find nested var decls in initializers (e.g., arrow function bodies) + var.visit_children_with(self); + } + + // Collect exported variable declarations + fn visit_export_decl(&mut self, export: &ExportDecl) { + if let Decl::Var(var) = &export.decl { + for declarator in &var.decls { + self.collect_bindings_from_pat(&declarator.name, true); + } + } + // Continue visiting to find nested var decls + export.visit_children_with(self); + } + + fn visit_import_decl(&mut self, import: &ImportDecl) { + for spec in &import.specifiers { + let id = match spec { + ImportSpecifier::Named(named) => &named.local, + ImportSpecifier::Default(default) => &default.local, + ImportSpecifier::Namespace(ns) => &ns.local, + }; + self.declared_bindings.insert(id.to_id(), false); + } + } +} + +impl DeclarationCollector<'_> { + fn collect_bindings_from_pat(&mut self, pat: &Pat, is_exported: bool) { + collect_bindings_from_pat(pat, is_exported, &mut |id, is_exp| { + self.declared_bindings.insert(id, is_exp); + }); + } +} + +/// Visitor that collects all binding usages/references. struct BindingCollector<'a> { used_bindings: &'a mut HashSet, declared_bindings: &'a HashMap, } +impl<'a> BindingCollector<'a> { + fn new(used_bindings: &'a mut HashSet, declared_bindings: &'a HashMap) -> Self { + Self { + used_bindings, + declared_bindings, + } + } +} + impl BindingCollector<'_> { - fn mark_used(&mut self, id: Id) { + fn mark_binding_used(&mut self, id: Id) { if self.declared_bindings.contains_key(&id) { self.used_bindings.insert(id); } @@ -124,7 +290,7 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Visit expressions to find identifier references fn visit_expr(&mut self, expr: &Expr) { if let Expr::Ident(ident) = expr { - self.mark_used(ident.to_id()); + self.mark_binding_used(ident.to_id()); } expr.visit_children_with(self); } @@ -132,7 +298,7 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Visit property shorthand: { foo } is a reference to foo fn visit_prop(&mut self, prop: &Prop) { if let Prop::Shorthand(ident) = prop { - self.mark_used(ident.to_id()); + self.mark_binding_used(ident.to_id()); } prop.visit_children_with(self); } @@ -148,7 +314,7 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { fn visit_export_decl(&mut self, export: &ExportDecl) { if let Decl::Var(var) = &export.decl { for declarator in &var.decls { - self.mark_exported_pat(&declarator.name); + self.mark_bindings_in_pat_as_used(&declarator.name); } } export.visit_children_with(self); @@ -164,18 +330,13 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Mark default export declarations as used (export default function foo() {}) fn visit_export_default_decl(&mut self, export: &ExportDefaultDecl) { - match &export.decl { - DefaultDecl::Fn(fn_expr) => { - if let Some(ident) = &fn_expr.ident { - self.used_bindings.insert(ident.to_id()); - } - } - DefaultDecl::Class(class_expr) => { - if let Some(ident) = &class_expr.ident { - self.used_bindings.insert(ident.to_id()); - } - } - DefaultDecl::TsInterfaceDecl(_) => {} + let ident = match &export.decl { + DefaultDecl::Fn(fn_expr) => fn_expr.ident.as_ref(), + DefaultDecl::Class(class_expr) => class_expr.ident.as_ref(), + DefaultDecl::TsInterfaceDecl(_) => None, + }; + if let Some(ident) = ident { + self.used_bindings.insert(ident.to_id()); } export.visit_children_with(self); } @@ -194,59 +355,16 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { } impl BindingCollector<'_> { - fn mark_exported_pat(&mut self, pat: &Pat) { - match pat { - Pat::Ident(ident) => { - self.used_bindings.insert(ident.id.to_id()); - } - Pat::Array(arr) => { - for elem in arr.elems.iter().flatten() { - self.mark_exported_pat(elem); - } - } - Pat::Object(obj) => { - for prop in &obj.props { - match prop { - ObjectPatProp::KeyValue(kv) => { - self.mark_exported_pat(&kv.value); - } - ObjectPatProp::Assign(assign) => { - self.used_bindings.insert(assign.key.to_id()); - } - ObjectPatProp::Rest(rest) => { - self.mark_exported_pat(&rest.arg); - } - } - } - } - Pat::Rest(rest) => self.mark_exported_pat(&rest.arg), - Pat::Assign(assign) => self.mark_exported_pat(&assign.left), - _ => {} - } + fn mark_bindings_in_pat_as_used(&mut self, pat: &Pat) { + collect_bindings_from_pat(pat, true, &mut |id, _| { + self.used_bindings.insert(id); + }); } } impl VisitMut for UnusedBindingsRemover { fn visit_mut_module(&mut self, module: &mut Module) { - // Collect declarations - for item in &module.body { - self.collect_declarations_from_module_item(item); - } - - // Collect usages - let mut collector = BindingCollector { - used_bindings: &mut self.used_bindings, - declared_bindings: &self.declared_bindings, - }; - module.visit_with(&mut collector); - - // Remove unused bindings - module.visit_mut_children_with(self); - - // Clean up empty declarations - module.body.retain( - |item| !matches!(item, ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) if var.decls.is_empty()), - ); + self.run_elimination_passes(module); } fn visit_mut_var_declarator(&mut self, node: &mut VarDeclarator) { @@ -256,68 +374,25 @@ impl VisitMut for UnusedBindingsRemover { fn visit_mut_var_decl(&mut self, node: &mut VarDecl) { node.visit_mut_children_with(self); - - node.decls.retain(|decl| match &decl.name { - Pat::Object(obj) if obj.props.is_empty() => false, - Pat::Array(arr) if arr.elems.iter().all(Option::is_none) => false, - Pat::Ident(ident) => { - let id = ident.id.to_id(); - self - .declared_bindings - .get(&id) - .is_none_or(|&is_exported| self.should_keep_binding(&id, is_exported)) - } - _ => true, - }); + node + .decls + .retain(|decl| !self.is_pattern_empty(&decl.name) && self.should_keep_declarator(decl)); } -} -impl UnusedBindingsRemover { - fn collect_declarations_from_module_item(&mut self, item: &ModuleItem) { - let (decl, is_exported) = match item { - ModuleItem::Stmt(Stmt::Decl(decl)) => (decl, false), - ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => (&export.decl, true), - _ => return, - }; + fn visit_mut_block_stmt(&mut self, block: &mut BlockStmt) { + block.visit_mut_children_with(self); + self.cleanup_empty_var_decls(&mut block.stmts); + } - if let Decl::Var(var) = decl { - for declarator in &var.decls { - self.collect_declarations_from_pat(&declarator.name, is_exported); - } - } + fn visit_mut_stmts(&mut self, stmts: &mut Vec) { + stmts.visit_mut_children_with(self); + self.cleanup_empty_var_decls(stmts); } - fn collect_declarations_from_pat(&mut self, pat: &Pat, is_exported: bool) { - match pat { - Pat::Ident(ident) => { - self.declared_bindings.insert(ident.id.to_id(), is_exported); - } - Pat::Array(arr) => { - for elem in arr.elems.iter().flatten() { - self.collect_declarations_from_pat(elem, is_exported); - } - } - Pat::Object(obj) => { - for prop in &obj.props { - match prop { - ObjectPatProp::KeyValue(kv) => { - self.collect_declarations_from_pat(&kv.value, is_exported); - } - ObjectPatProp::Assign(assign) => { - self - .declared_bindings - .insert(assign.key.to_id(), is_exported); - } - ObjectPatProp::Rest(rest) => { - self.collect_declarations_from_pat(&rest.arg, is_exported); - } - } - } - } - Pat::Rest(rest) => self.collect_declarations_from_pat(&rest.arg, is_exported), - Pat::Assign(assign) => self.collect_declarations_from_pat(&assign.left, is_exported), - _ => {} - } + fn visit_mut_import_decl(&mut self, import: &mut ImportDecl) { + import + .specifiers + .retain(|spec| self.should_keep_import_spec(spec)); } } @@ -425,28 +500,12 @@ mod tests { } #[test] - fn test_keeps_react_identifier() { + fn test_keeps_special_identifiers() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const React = require('react'); - const unused = 1; - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const React = require('react'); - "#} - ); - } - - #[test] - fn test_keeps_di_identifier() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" const di = something; + const jsx = other; const unused = 1; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), @@ -455,7 +514,9 @@ mod tests { assert_eq!( output_code, indoc! {r#" + const React = require('react'); const di = something; + const jsx = other; "#} ); } @@ -757,10 +818,303 @@ mod tests { indoc! {r#" (function(global, factory) { module.exports = factory(); - })(this, function() { + })(this, (function() { const lottie = {}; return lottie; - }); + })); + "#} + ); + } + + #[test] + fn test_multi_pass_removes_cascading_unused_bindings() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const a = 1; + const b = a; + const c = b; + const d = c; + console.log('hello'); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + console.log('hello'); + "#} + ); + } + + #[test] + fn test_multi_pass_partial_chain() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const a = 1; + const b = a; + const c = b; + const d = c; + console.log(b); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const a = 1; + const b = a; + console.log(b); + "#} + ); + } + + #[test] + fn test_removes_unused_in_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + const unused = 1; + const used = 2; + console.log(used); + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + const used = 2; + console.log(used); + } + "#} + ); + } + + #[test] + fn test_removes_unused_in_block() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + { + const unused = 42; + console.log('hello'); + } + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + { + console.log('hello'); + } + } + "#} + ); + } + + #[test] + fn test_unused_after_used_in_arrow() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const inner = () => { + const c = 1; + const unusedInner = c; + console.log(c); + }; + inner(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const inner = ()=>{ + const c = 1; + console.log(c); + }; + inner(); + "#} + ); + } + + #[test] + fn test_multi_pass_scopes() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import used from 'used'; + import unused from 'unused'; + + outer(); + + function outer() { + const a = 1; + const b = a; + + const inner = () => { + { + const unusedBlockInner = 42; + used(); + } + + const c = b; + const unusedInner = c; + console.log(c); + }; + + inner(); + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import used from 'used'; + outer(); + function outer() { + const a = 1; + const b = a; + const inner = ()=>{ + { + used(); + } + const c = b; + console.log(c); + }; + inner(); + } + "#} + ); + } + + #[test] + fn test_removes_unused_from_object_rest_pattern() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, ...rest } = obj; + console.log(a); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { a } = obj; + console.log(a); + "#} + ); + } + + #[test] + fn test_keeps_used_object_rest_pattern() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, ...rest } = obj; + console.log(rest); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { ...rest } = obj; + console.log(rest); + "#} + ); + } + + #[test] + fn test_removes_unused_from_array_rest_pattern() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, ...rest] = arr; + console.log(a); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [a, ...rest] = arr; + console.log(a); + "#} + ); + } + + #[test] + fn test_keeps_used_array_rest_pattern() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, ...rest] = arr; + console.log(rest); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [, ...rest] = arr; + console.log(rest); + "#} + ); + } + + #[test] + fn test_keeps_shorthand_property_usage() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const foo = 1; + const unused = 2; + const obj = { foo }; + export default obj; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const foo = 1; + const obj = { + foo + }; + export default obj; + "#} + ); + } + + #[test] + fn test_keeps_named_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const foo = 1; + const bar = 2; + const unused = 3; + export { foo, bar }; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const foo = 1; + const bar = 2; + export { foo, bar }; "#} ); } From 2daa86b5ae5b2289e141be39ddc4768b93b73d66 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Fri, 24 Oct 2025 12:43:35 +1100 Subject: [PATCH 07/22] Fix for indexing with variables not being counted --- .../js/core/src/unused_bindings_remover.rs | 222 +++++++++++++++++- 1 file changed, 220 insertions(+), 2 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 9406ed19e..1708a71ab 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -343,9 +343,23 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Handle assignment expressions - only visit the right side (value being assigned) fn visit_assign_expr(&mut self, assign: &AssignExpr) { - // Only visit right side - left side is assignment target, not a usage + // For member expressions like obj.prop = value or obj[key] = value, + // visit the entire member expression (obj and computed key if present) + if let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assign.left { + member.visit_with(self); + } + // Visit right side assign.right.visit_with(self); - // Don't visit left side or call visit_children_with to avoid visiting assignment target + } + + // Handle member expressions to mark computed property keys as used + fn visit_member_expr(&mut self, member: &MemberExpr) { + // Visit the object being accessed + member.obj.visit_with(self); + // For computed properties like obj[key], visit the key + if let MemberProp::Computed(computed) = &member.prop { + computed.expr.visit_with(self); + } } // Don't visit patterns (they're declarations, not references) @@ -1118,4 +1132,208 @@ mod tests { "#} ); } + + #[test] + fn test_simple_var_usage() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + var x = 1; + console.log(x); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + var x = 1; + console.log(x); + "#} + ); + } + + #[test] + fn test_var_used_in_member_assignment() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + var curve = exports; + curve.short = require('./short'); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + var curve = exports; + curve.short = require('./short'); + "#} + ); + } + + #[test] + fn test_var_used_as_array_index() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const index = 0; + const value = someArray[index]; + console.log(value); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const index = 0; + const value = someArray[index]; + console.log(value); + "#} + ); + } + + #[test] + fn test_var_used_as_computed_property() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const key = 'foo'; + const value = obj[key]; + console.log(value); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const key = 'foo'; + const value = obj[key]; + console.log(value); + "#} + ); + } + + #[test] + fn test_var_property_index_assign() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const obj = {}; + const key = 'foo'; + obj[key] = 'bar'; + console.log(obj); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const obj = {}; + const key = 'foo'; + obj[key] = 'bar'; + console.log(obj); + "#} + ); + } + + #[test] + fn test_var_array_index_assign() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const arr = []; + const index = 0; + arr[index] = 'value'; + console.log(arr); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const arr = []; + const index = 0; + arr[index] = 'value'; + console.log(arr); + "#} + ); + } + + #[test] + fn test_var_used_in_for_loop_body() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + let retryDelay = 1000; + for(let i = 0; i < 5; i++)retryDelay *= 2; + console.log(retryDelay); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + let retryDelay = 1000; + for(let i = 0; i < 5; i++)retryDelay *= 2; + console.log(retryDelay); + "#} + ); + } + + #[test] + fn test_var_used_in_compound_expression() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; + return v.toString(16); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; + return v.toString(16); + "#} + ); + } + + #[test] + fn test_multiple_declarators_both_used() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const a = 1, b = 2; + console.log(a, b); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const a = 1, b = 2; + console.log(a, b); + "#} + ); + } + + #[test] + fn test_multiple_declarators_first_unused() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const unused = 1, used = 2; + console.log(used); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const used = 2; + console.log(used); + "#} + ); + } } From 7c0f6c6607e30f448361af793b71b30ed6e52a57 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Fri, 24 Oct 2025 14:02:28 +1100 Subject: [PATCH 08/22] Wip fixing discrepancies, latest new tests failing --- .../js/core/src/unused_bindings_remover.rs | 143 +++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 1708a71ab..7e91dc390 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -341,7 +341,7 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { export.visit_children_with(self); } - // Handle assignment expressions - only visit the right side (value being assigned) + // Handle assignment expressions - visit both sides appropriately fn visit_assign_expr(&mut self, assign: &AssignExpr) { // For member expressions like obj.prop = value or obj[key] = value, // visit the entire member expression (obj and computed key if present) @@ -363,8 +363,26 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { } // Don't visit patterns (they're declarations, not references) - fn visit_pat(&mut self, _pat: &Pat) { - // Skip - patterns are declarations not usages + // But we need to visit default values in patterns + fn visit_pat(&mut self, pat: &Pat) { + match pat { + Pat::Assign(assign) => { + // Visit the default value (right side) to collect any usages within it + assign.right.visit_with(self); + } + Pat::Object(obj) => { + // Visit default values in object pattern properties + for prop in &obj.props { + if let ObjectPatProp::Assign(assign) = prop { + if let Some(value) = &assign.value { + value.visit_with(self); + } + } + } + } + _ => {} + } + // Don't visit other children - patterns themselves are declarations not usages } } @@ -1336,4 +1354,123 @@ mod tests { "#} ); } + + #[test] + fn test_compound_assignment_operator() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + let count = 0; + count += 1; + console.log(count); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + let count = 0; + count += 1; + console.log(count); + "#} + ); + } + + #[test] + fn test_increment_operator() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + let i = 0; + i++; + console.log(i); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + let i = 0; + i++; + console.log(i); + "#} + ); + } + + #[test] + fn test_compound_assignment_in_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + let count = 0; + count += 1; + console.log(count); + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + let count = 0; + count += 1; + console.log(count); + } + "#} + ); + } + + #[test] + fn test_var_in_auto_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + (function() { + var count = 0; + if (true) {count = 1;} + console.log(count); + })(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + (function() { + var count = 0; + if (true) {count = 1;} + console.log(count); + })(); + "#} + ); + } + + #[test] + fn test_var_in_destructured_function_default() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { func = function() { + let count = 0; + count += 1; + console.log(count); + } } = options; + func(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { func = function() { + let count = 0; + count += 1; + console.log(count); + } } = options; + func(); + "#} + ); + } } From 6e5940f2d19c51dbf345096e293990647c702fd5 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 27 Oct 2025 17:10:45 +1100 Subject: [PATCH 09/22] Improve `UnusedBindingsRemover` handling of object and array destructuring --- .../js/core/src/unused_bindings_remover.rs | 268 ++++++++++++++++-- 1 file changed, 249 insertions(+), 19 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 7e91dc390..f1fe4f2e1 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -98,6 +98,7 @@ impl UnusedBindingsRemover { } Pat::Object(obj) => obj.props.is_empty(), Pat::Array(arr) => arr.elems.iter().all(Option::is_none), + Pat::Rest(rest) => self.is_pattern_empty(&rest.arg), _ => false, } } @@ -177,16 +178,26 @@ impl UnusedBindingsRemover { fn remove_from_pat(&self, pat: &mut Pat) { match pat { Pat::Object(obj) => { + let mut has_rest = false; + // Recursively process nested patterns for prop in &mut obj.props { match prop { ObjectPatProp::KeyValue(kv) => self.remove_from_pat(&mut kv.value), - ObjectPatProp::Rest(rest) => self.remove_from_pat(&mut rest.arg), + ObjectPatProp::Rest(rest) => { + has_rest = true; + self.remove_from_pat(&mut rest.arg); + } _ => {} } } - // Remove unused props + if has_rest { + // Don't remove any properties to not affect the rest pattern + return; + } + + // Remove unused properties obj.props.retain(|prop| match prop { ObjectPatProp::KeyValue(kv) => !self.is_pattern_empty(&kv.value), ObjectPatProp::Assign(assign) => self.used_bindings.contains(&assign.key.to_id()), @@ -199,12 +210,18 @@ impl UnusedBindingsRemover { self.remove_from_pat(elem); } - // Remove unused elements + // Replace unused elements with holes (None), which creates valid JS like [a, , c] + // This preserves array positions while removing unused bindings for elem in &mut arr.elems { if matches!(elem, Some(p) if self.is_pattern_empty(p)) { *elem = None; } } + + // Trim trailing holes to avoid unnecessary commas like [a, , , ] + while arr.elems.last().is_some_and(|e| e.is_none()) { + arr.elems.pop(); + } } _ => {} } @@ -343,10 +360,20 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Handle assignment expressions - visit both sides appropriately fn visit_assign_expr(&mut self, assign: &AssignExpr) { - // For member expressions like obj.prop = value or obj[key] = value, - // visit the entire member expression (obj and computed key if present) - if let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assign.left { - member.visit_with(self); + match &assign.left { + // For simple identifier assignments like foo = 1, mark the identifier as used + AssignTarget::Simple(SimpleAssignTarget::Ident(ident)) => { + self.mark_binding_used(ident.id.to_id()); + } + // For member expressions like obj.prop = value or obj[key] = value, + // visit the entire member expression (obj and computed key if present) + AssignTarget::Simple(SimpleAssignTarget::Member(member)) => { + member.visit_with(self); + } + // For other assignment targets, use default visiting + _ => { + assign.left.visit_with(self); + } } // Visit right side assign.right.visit_with(self); @@ -371,12 +398,22 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { assign.right.visit_with(self); } Pat::Object(obj) => { - // Visit default values in object pattern properties + // Visit computed property keys and default values in object pattern properties for prop in &obj.props { - if let ObjectPatProp::Assign(assign) = prop { - if let Some(value) = &assign.value { - value.visit_with(self); + match prop { + ObjectPatProp::KeyValue(kv) => { + // Visit computed property keys like { [key]: value } + if let PropName::Computed(computed) = &kv.key { + computed.expr.visit_with(self); + } + } + ObjectPatProp::Assign(assign) => { + // Visit default values like { foo = defaultValue } + if let Some(value) = &assign.value { + value.visit_with(self); + } } + _ => {} } } } @@ -426,6 +463,18 @@ impl VisitMut for UnusedBindingsRemover { .specifiers .retain(|spec| self.should_keep_import_spec(spec)); } + + fn visit_mut_for_in_stmt(&mut self, node: &mut ForInStmt) { + // Don't remove the loop variable, only visit the body + node.right.visit_mut_with(self); + node.body.visit_mut_with(self); + } + + fn visit_mut_for_of_stmt(&mut self, node: &mut ForOfStmt) { + // Don't remove the loop variable, only visit the body + node.right.visit_mut_with(self); + node.body.visit_mut_with(self); + } } #[cfg(test)] @@ -637,11 +686,10 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Note: The output has a trailing comma which is valid JS assert_eq!( output_code, indoc! {r#" - const [a, [, c], ] = arr; + const [a, [, c]] = arr; console.log(a, c); "#} ); @@ -657,7 +705,6 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Multiple holes in array destructuring is valid JS: [a, , , , e] assert_eq!( output_code, indoc! {r#" @@ -677,7 +724,7 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); - // The nested array [b, c] becomes empty and should be removed entirely + // The nested array becomes a hole to preserve d's position assert_eq!( output_code, indoc! {r#" @@ -1039,10 +1086,11 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); + // Cannot remove rest when present - it affects semantics assert_eq!( output_code, indoc! {r#" - const { a } = obj; + const { a, ...rest } = obj; console.log(a); "#} ); @@ -1058,10 +1106,11 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); + // Cannot remove `a` when rest is present - removing affects rest contents assert_eq!( output_code, indoc! {r#" - const { ...rest } = obj; + const { a, ...rest } = obj; console.log(rest); "#} ); @@ -1077,10 +1126,11 @@ mod tests { |_: RunTestContext| UnusedBindingsRemover::new(), ); + // Can safely remove unused rest assert_eq!( output_code, indoc! {r#" - const [a, ...rest] = arr; + const [a] = arr; console.log(a); "#} ); @@ -1440,7 +1490,9 @@ mod tests { indoc! {r#" (function() { var count = 0; - if (true) {count = 1;} + if (true) { + count = 1; + } console.log(count); })(); "#} @@ -1473,4 +1525,182 @@ mod tests { "#} ); } + + #[test] + fn test_var_assign_only() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + let foo = 0; + foo = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + let foo = 0; + foo = 1; + "#} + ); + } + + #[test] + fn test_unused_iteration_variable() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + for (var foo in []) { + console.log("Hello"); + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + for(var foo in []){ + console.log("Hello"); + } + "#} + ); + } + + #[test] + fn test_computed_property_exclusion_with_rest() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const key = 'excluded'; + const { [key]: _, ...rest } = obj; + console.log(rest); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const key = 'excluded'; + const { [key]: _, ...rest } = obj; + console.log(rest); + "#} + ); + } + + #[test] + fn test_object_rest_allows_removal_without_computed() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, b, ...rest } = obj; + console.log(rest); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + // Cannot remove `a` or `b` when rest is present - removing affects rest contents + assert_eq!( + output_code, + indoc! {r#" + const { a, b, ...rest } = obj; + console.log(rest); + "#} + ); + } + + #[test] + fn test_object_without_rest_can_remove_properties() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { a, b, c } = obj; + console.log(a); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { a } = obj; + console.log(a); + "#} + ); + } + + #[test] + fn test_computed_property_without_rest() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const key = 'prop'; + const { [key]: value, other } = obj; + console.log(value); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const key = 'prop'; + const { [key]: value } = obj; + console.log(value); + "#} + ); + } + + #[test] + fn test_array_destructuring_preserves_position() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [foo, bar] = arr; + console.log(bar); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [, bar] = arr; + console.log(bar); + "#} + ); + } + + #[test] + fn test_array_destructuring_trailing_commas_removed() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, b, c, d] = arr; + console.log(a); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [a] = arr; + console.log(a); + "#} + ); + } + + #[test] + fn test_array_destructuring_middle_unused() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const [a, b, c] = arr; + console.log(a, c); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const [a, , c] = arr; + console.log(a, c); + "#} + ); + } } From c7661349ac8b756e8a696f699fe6e7196c897982 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 27 Oct 2025 17:22:11 +1100 Subject: [PATCH 10/22] Simplify `unused_bindings_remover` tests --- .../js/core/src/unused_bindings_remover.rs | 390 +++--------------- 1 file changed, 47 insertions(+), 343 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index f1fe4f2e1..7a162e6cb 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -543,25 +543,6 @@ mod tests { ); } - #[test] - fn test_removes_unused_from_array_destructuring() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const [a, b, c] = arr; - console.log(a, c); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const [a, , c] = arr; - console.log(a, c); - "#} - ); - } - #[test] fn test_removes_entire_declaration_if_all_unused() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -1077,80 +1058,44 @@ mod tests { } #[test] - fn test_removes_unused_from_object_rest_pattern() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const { a, ...rest } = obj; - console.log(a); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - // Cannot remove rest when present - it affects semantics - assert_eq!( - output_code, - indoc! {r#" - const { a, ...rest } = obj; - console.log(a); - "#} - ); - } - - #[test] - fn test_keeps_used_object_rest_pattern() { + fn test_object_rest_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const { a, ...rest } = obj; - console.log(rest); + const { b, ...rest2 } = obj2; + console.log(a, rest2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Cannot remove `a` when rest is present - removing affects rest contents + // Cannot remove properties when rest is present - affects rest contents assert_eq!( output_code, indoc! {r#" const { a, ...rest } = obj; - console.log(rest); + const { b, ...rest2 } = obj2; + console.log(a, rest2); "#} ); } #[test] - fn test_removes_unused_from_array_rest_pattern() { + fn test_array_rest_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const [a, ...rest] = arr; - console.log(a); + const [b, ...rest2] = arr2; + console.log(a, rest2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Can safely remove unused rest assert_eq!( output_code, indoc! {r#" const [a] = arr; - console.log(a); - "#} - ); - } - - #[test] - fn test_keeps_used_array_rest_pattern() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const [a, ...rest] = arr; - console.log(rest); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const [, ...rest] = arr; - console.log(rest); + const [, ...rest2] = arr2; + console.log(a, rest2); "#} ); } @@ -1201,25 +1146,6 @@ mod tests { ); } - #[test] - fn test_simple_var_usage() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - var x = 1; - console.log(x); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - var x = 1; - console.log(x); - "#} - ); - } - #[test] fn test_var_used_in_member_assignment() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -1240,33 +1166,14 @@ mod tests { } #[test] - fn test_var_used_as_array_index() { + fn test_var_used_in_computed_access() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const index = 0; - const value = someArray[index]; - console.log(value); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const index = 0; - const value = someArray[index]; - console.log(value); - "#} - ); - } - - #[test] - fn test_var_used_as_computed_property() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" const key = 'foo'; - const value = obj[key]; - console.log(value); + const value1 = someArray[index]; + const value2 = obj[key]; + console.log(value1, value2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1274,44 +1181,26 @@ mod tests { assert_eq!( output_code, indoc! {r#" + const index = 0; const key = 'foo'; - const value = obj[key]; - console.log(value); + const value1 = someArray[index]; + const value2 = obj[key]; + console.log(value1, value2); "#} ); } #[test] - fn test_var_property_index_assign() { + fn test_var_used_in_computed_assignment() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const obj = {}; - const key = 'foo'; - obj[key] = 'bar'; - console.log(obj); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const obj = {}; - const key = 'foo'; - obj[key] = 'bar'; - console.log(obj); - "#} - ); - } - - #[test] - fn test_var_array_index_assign() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" const arr = []; + const key = 'foo'; const index = 0; + obj[key] = 'bar'; arr[index] = 'value'; - console.log(arr); + console.log(obj, arr); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1319,10 +1208,13 @@ mod tests { assert_eq!( output_code, indoc! {r#" + const obj = {}; const arr = []; + const key = 'foo'; const index = 0; + obj[key] = 'bar'; arr[index] = 'value'; - console.log(arr); + console.log(obj, arr); "#} ); } @@ -1368,30 +1260,12 @@ mod tests { } #[test] - fn test_multiple_declarators_both_used() { + fn test_multiple_declarators() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const a = 1, b = 2; - console.log(a, b); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const a = 1, b = 2; - console.log(a, b); - "#} - ); - } - - #[test] - fn test_multiple_declarators_first_unused() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" const unused = 1, used = 2; - console.log(used); + console.log(a, b, used); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1399,40 +1273,22 @@ mod tests { assert_eq!( output_code, indoc! {r#" + const a = 1, b = 2; const used = 2; - console.log(used); + console.log(a, b, used); "#} ); } #[test] - fn test_compound_assignment_operator() { + fn test_mutation_operators() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" let count = 0; - count += 1; - console.log(count); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - let count = 0; - count += 1; - console.log(count); - "#} - ); - } - - #[test] - fn test_increment_operator() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" let i = 0; + count += 1; i++; - console.log(i); + console.log(count, i); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1440,88 +1296,11 @@ mod tests { assert_eq!( output_code, indoc! {r#" + let count = 0; let i = 0; + count += 1; i++; - console.log(i); - "#} - ); - } - - #[test] - fn test_compound_assignment_in_function() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - function foo() { - let count = 0; - count += 1; - console.log(count); - } - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - function foo() { - let count = 0; - count += 1; - console.log(count); - } - "#} - ); - } - - #[test] - fn test_var_in_auto_function() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - (function() { - var count = 0; - if (true) {count = 1;} - console.log(count); - })(); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - (function() { - var count = 0; - if (true) { - count = 1; - } - console.log(count); - })(); - "#} - ); - } - - #[test] - fn test_var_in_destructured_function_default() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const { func = function() { - let count = 0; - count += 1; - console.log(count); - } } = options; - func(); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const { func = function() { - let count = 0; - count += 1; - console.log(count); - } } = options; - func(); + console.log(count, i); "#} ); } @@ -1588,109 +1367,33 @@ mod tests { } #[test] - fn test_object_rest_allows_removal_without_computed() { + fn test_object_rest_vs_no_rest() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const { a, b, ...rest } = obj; - console.log(rest); + const { c, d, e } = obj2; + console.log(rest, c); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Cannot remove `a` or `b` when rest is present - removing affects rest contents assert_eq!( output_code, indoc! {r#" const { a, b, ...rest } = obj; - console.log(rest); - "#} - ); - } - - #[test] - fn test_object_without_rest_can_remove_properties() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const { a, b, c } = obj; - console.log(a); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const { a } = obj; - console.log(a); - "#} - ); - } - - #[test] - fn test_computed_property_without_rest() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const key = 'prop'; - const { [key]: value, other } = obj; - console.log(value); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const key = 'prop'; - const { [key]: value } = obj; - console.log(value); - "#} - ); - } - - #[test] - fn test_array_destructuring_preserves_position() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const [foo, bar] = arr; - console.log(bar); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const [, bar] = arr; - console.log(bar); - "#} - ); - } - - #[test] - fn test_array_destructuring_trailing_commas_removed() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const [a, b, c, d] = arr; - console.log(a); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); - - assert_eq!( - output_code, - indoc! {r#" - const [a] = arr; - console.log(a); + const { c } = obj2; + console.log(rest, c); "#} ); } #[test] - fn test_array_destructuring_middle_unused() { + fn test_array_destructuring_holes_and_trimming() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" const [a, b, c] = arr; - console.log(a, c); + const [d, e, f, g] = arr2; + console.log(a, c, d); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1699,7 +1402,8 @@ mod tests { output_code, indoc! {r#" const [a, , c] = arr; - console.log(a, c); + const [d] = arr2; + console.log(a, c, d); "#} ); } From a4bd332e9a63c025c7a9f5b531286b4088212376 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 27 Oct 2025 17:23:34 +1100 Subject: [PATCH 11/22] Reorganise `unused_bindings_remover` tests --- .../js/core/src/unused_bindings_remover.rs | 659 +++++++++--------- 1 file changed, 342 insertions(+), 317 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 7a162e6cb..6340069a5 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -483,6 +483,10 @@ mod tests { use atlaspack_swc_runner::test_utils::{RunTestContext, RunVisitResult, run_test_visit}; use indoc::indoc; + // =========================================================================== + // Basic variable removal + // =========================================================================== + #[test] fn test_removes_unused_variable() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -524,6 +528,53 @@ mod tests { ); } + #[test] + fn test_multiple_declarators() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const a = 1, b = 2; + const unused = 1, used = 2; + console.log(a, b, used); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const a = 1, b = 2; + const used = 2; + console.log(a, b, used); + "#} + ); + } + + #[test] + fn test_keeps_special_identifiers() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const React = require('react'); + const di = something; + const jsx = other; + const unused = 1; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const React = require('react'); + const di = something; + const jsx = other; + "#} + ); + } + + // =========================================================================== + // Object destructuring + // =========================================================================== + #[test] fn test_removes_unused_from_object_destructuring() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -562,13 +613,11 @@ mod tests { } #[test] - fn test_keeps_special_identifiers() { + fn test_handles_nested_destructuring() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const React = require('react'); - const di = something; - const jsx = other; - const unused = 1; + const { a, b: { c, d } } = obj; + console.log(a, c); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -576,19 +625,18 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const React = require('react'); - const di = something; - const jsx = other; + const { a, b: { c } } = obj; + console.log(a, c); "#} ); } #[test] - fn test_keeps_exports() { + fn test_handles_deeply_nested_destructuring() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - export const exported = 1; - const notExported = 2; + const { a: { b: { c, d }, e }, f } = obj; + console.log(c, f); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -596,35 +644,41 @@ mod tests { assert_eq!( output_code, indoc! {r#" - export const exported = 1; + const { a: { b: { c } }, f } = obj; + console.log(c, f); "#} ); } #[test] - fn test_keeps_structured_exports() { + fn test_object_rest_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - export const { a, b } = obj; - const { unused } = obj; + const { a, ...rest } = obj; + const { b, ...rest2 } = obj2; + console.log(a, rest2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); + // Cannot remove properties when rest is present - affects rest contents assert_eq!( output_code, indoc! {r#" - export const { a, b } = obj; + const { a, ...rest } = obj; + const { b, ...rest2 } = obj2; + console.log(a, rest2); "#} ); } #[test] - fn test_handles_nested_destructuring() { + fn test_object_rest_vs_no_rest() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const { a, b: { c, d } } = obj; - console.log(a, c); + const { a, b, ...rest } = obj; + const { c, d, e } = obj2; + console.log(rest, c); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -632,18 +686,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const { a, b: { c } } = obj; - console.log(a, c); + const { a, b, ...rest } = obj; + const { c } = obj2; + console.log(rest, c); "#} ); } #[test] - fn test_handles_deeply_nested_destructuring() { + fn test_computed_property_exclusion_with_rest() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const { a: { b: { c, d }, e }, f } = obj; - console.log(c, f); + const key = 'excluded'; + const { [key]: _, ...rest } = obj; + console.log(rest); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -651,12 +707,17 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const { a: { b: { c } }, f } = obj; - console.log(c, f); + const key = 'excluded'; + const { [key]: _, ...rest } = obj; + console.log(rest); "#} ); } + // =========================================================================== + // Array destructuring + // =========================================================================== + #[test] fn test_handles_nested_array_destructuring() { let RunVisitResult { output_code, .. } = run_test_visit( @@ -716,13 +777,12 @@ mod tests { } #[test] - fn test_keeps_variables_used_in_functions() { + fn test_array_destructuring_holes_and_trimming() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const x = 1; - function foo() { - return x; - } + const [a, b, c] = arr; + const [d, e, f, g] = arr2; + console.log(a, c, d); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -730,21 +790,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const x = 1; - function foo() { - return x; - } + const [a, , c] = arr; + const [d] = arr2; + console.log(a, c, d); "#} ); } #[test] - fn test_keeps_default_export() { + fn test_array_rest_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const obj = {}; - const unused = 1; - export default obj; + const [a, ...rest] = arr; + const [b, ...rest2] = arr2; + console.log(a, rest2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -752,18 +811,23 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const obj = {}; - export default obj; + const [a] = arr; + const [, ...rest2] = arr2; + console.log(a, rest2); "#} ); } + // =========================================================================== + // Exports + // =========================================================================== + #[test] - fn test_keeps_default_function_export() { + fn test_keeps_exports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const unused = 1; - export default function foo() {} + export const exported = 1; + const notExported = 2; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -771,17 +835,17 @@ mod tests { assert_eq!( output_code, indoc! {r#" - export default function foo() {} + export const exported = 1; "#} ); } #[test] - fn test_preserves_reexports() { + fn test_keeps_structured_exports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - export { default as LottiePlayer } from 'lottie-web'; - const unused = 1; + export const { a, b } = obj; + const { unused } = obj; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -789,20 +853,18 @@ mod tests { assert_eq!( output_code, indoc! {r#" - export { default as LottiePlayer } from 'lottie-web'; + export const { a, b } = obj; "#} ); } #[test] - fn test_keeps_object_used_in_member_expressions() { + fn test_keeps_default_export() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const lottie = {}; - lottie.play = function() {}; - lottie.pause = function() {}; + const obj = {}; const unused = 1; - export default lottie; + export default obj; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -810,21 +872,18 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const lottie = {}; - lottie.play = function() {}; - lottie.pause = function() {}; - export default lottie; + const obj = {}; + export default obj; "#} ); } #[test] - fn test_keeps_cjs_module_exports() { + fn test_keeps_default_function_export() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const foo = 1; - const unused = 2; - module.exports = foo; + const unused = 1; + export default function foo() {} "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -832,19 +891,19 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const foo = 1; - module.exports = foo; + export default function foo() {} "#} ); } #[test] - fn test_keeps_cjs_property_exports() { + fn test_keeps_named_exports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const bar = 1; - const unused = 2; - exports.default = bar; + const foo = 1; + const bar = 2; + const unused = 3; + export { foo, bar }; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -852,22 +911,18 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const bar = 1; - exports.default = bar; + const foo = 1; + const bar = 2; + export { foo, bar }; "#} ); } #[test] - fn test_keeps_factory_in_iife_pattern() { + fn test_preserves_reexports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - (function (global, factory) { - module.exports = factory(); - })(this, (function () { - const lottie = {}; - return lottie; - })); + export { default as LottiePlayer } from 'lottie-web'; const unused = 1; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), @@ -876,25 +931,18 @@ mod tests { assert_eq!( output_code, indoc! {r#" - (function(global, factory) { - module.exports = factory(); - })(this, (function() { - const lottie = {}; - return lottie; - })); + export { default as LottiePlayer } from 'lottie-web'; "#} ); } #[test] - fn test_multi_pass_removes_cascading_unused_bindings() { + fn test_keeps_cjs_module_exports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const a = 1; - const b = a; - const c = b; - const d = c; - console.log('hello'); + const foo = 1; + const unused = 2; + module.exports = foo; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -902,20 +950,19 @@ mod tests { assert_eq!( output_code, indoc! {r#" - console.log('hello'); + const foo = 1; + module.exports = foo; "#} ); } #[test] - fn test_multi_pass_partial_chain() { + fn test_keeps_cjs_property_exports() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const a = 1; - const b = a; - const c = b; - const d = c; - console.log(b); + const bar = 1; + const unused = 2; + exports.default = bar; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -923,21 +970,23 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const a = 1; - const b = a; - console.log(b); + const bar = 1; + exports.default = bar; "#} ); } + // =========================================================================== + // Variable usage patterns + // =========================================================================== + #[test] - fn test_removes_unused_in_function() { + fn test_keeps_variables_used_in_functions() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" + const x = 1; function foo() { - const unused = 1; - const used = 2; - console.log(used); + return x; } "#}, |_: RunTestContext| UnusedBindingsRemover::new(), @@ -946,24 +995,22 @@ mod tests { assert_eq!( output_code, indoc! {r#" + const x = 1; function foo() { - const used = 2; - console.log(used); + return x; } "#} ); } #[test] - fn test_removes_unused_in_block() { + fn test_keeps_shorthand_property_usage() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - function foo() { - { - const unused = 42; - console.log('hello'); - } - } + const foo = 1; + const unused = 2; + const obj = { foo }; + export default obj; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -971,25 +1018,24 @@ mod tests { assert_eq!( output_code, indoc! {r#" - function foo() { - { - console.log('hello'); - } - } + const foo = 1; + const obj = { + foo + }; + export default obj; "#} ); } #[test] - fn test_unused_after_used_in_arrow() { + fn test_keeps_object_used_in_member_expressions() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const inner = () => { - const c = 1; - const unusedInner = c; - console.log(c); - }; - inner(); + const lottie = {}; + lottie.play = function() {}; + lottie.pause = function() {}; + const unused = 1; + export default lottie; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -997,41 +1043,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const inner = ()=>{ - const c = 1; - console.log(c); - }; - inner(); + const lottie = {}; + lottie.play = function() {}; + lottie.pause = function() {}; + export default lottie; "#} ); } #[test] - fn test_multi_pass_scopes() { + fn test_var_used_in_member_assignment() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - import used from 'used'; - import unused from 'unused'; - - outer(); - - function outer() { - const a = 1; - const b = a; - - const inner = () => { - { - const unusedBlockInner = 42; - used(); - } - - const c = b; - const unusedInner = c; - console.log(c); - }; - - inner(); - } + var curve = exports; + curve.short = require('./short'); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1039,53 +1064,48 @@ mod tests { assert_eq!( output_code, indoc! {r#" - import used from 'used'; - outer(); - function outer() { - const a = 1; - const b = a; - const inner = ()=>{ - { - used(); - } - const c = b; - console.log(c); - }; - inner(); - } + var curve = exports; + curve.short = require('./short'); "#} ); } #[test] - fn test_object_rest_pattern() { + fn test_var_used_in_computed_access() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const { a, ...rest } = obj; - const { b, ...rest2 } = obj2; - console.log(a, rest2); + const index = 0; + const key = 'foo'; + const value1 = someArray[index]; + const value2 = obj[key]; + console.log(value1, value2); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); - // Cannot remove properties when rest is present - affects rest contents assert_eq!( output_code, indoc! {r#" - const { a, ...rest } = obj; - const { b, ...rest2 } = obj2; - console.log(a, rest2); + const index = 0; + const key = 'foo'; + const value1 = someArray[index]; + const value2 = obj[key]; + console.log(value1, value2); "#} ); } #[test] - fn test_array_rest_pattern() { + fn test_var_used_in_computed_assignment() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const [a, ...rest] = arr; - const [b, ...rest2] = arr2; - console.log(a, rest2); + const obj = {}; + const arr = []; + const key = 'foo'; + const index = 0; + obj[key] = 'bar'; + arr[index] = 'value'; + console.log(obj, arr); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1093,21 +1113,23 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const [a] = arr; - const [, ...rest2] = arr2; - console.log(a, rest2); + const obj = {}; + const arr = []; + const key = 'foo'; + const index = 0; + obj[key] = 'bar'; + arr[index] = 'value'; + console.log(obj, arr); "#} ); } #[test] - fn test_keeps_shorthand_property_usage() { + fn test_var_used_in_compound_expression() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const foo = 1; - const unused = 2; - const obj = { foo }; - export default obj; + const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; + return v.toString(16); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1115,23 +1137,21 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const foo = 1; - const obj = { - foo - }; - export default obj; + const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; + return v.toString(16); "#} ); } #[test] - fn test_keeps_named_exports() { + fn test_mutation_operators() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const foo = 1; - const bar = 2; - const unused = 3; - export { foo, bar }; + let count = 0; + let i = 0; + count += 1; + i++; + console.log(count, i); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1139,19 +1159,21 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const foo = 1; - const bar = 2; - export { foo, bar }; + let count = 0; + let i = 0; + count += 1; + i++; + console.log(count, i); "#} ); } #[test] - fn test_var_used_in_member_assignment() { + fn test_var_assign_only() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - var curve = exports; - curve.short = require('./short'); + let foo = 0; + foo = 1; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1159,21 +1181,22 @@ mod tests { assert_eq!( output_code, indoc! {r#" - var curve = exports; - curve.short = require('./short'); + let foo = 0; + foo = 1; "#} ); } + // Scoped contexts #[test] - fn test_var_used_in_computed_access() { + fn test_removes_unused_in_function() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const index = 0; - const key = 'foo'; - const value1 = someArray[index]; - const value2 = obj[key]; - console.log(value1, value2); + function foo() { + const unused = 1; + const used = 2; + console.log(used); + } "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1181,26 +1204,24 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const index = 0; - const key = 'foo'; - const value1 = someArray[index]; - const value2 = obj[key]; - console.log(value1, value2); + function foo() { + const used = 2; + console.log(used); + } "#} ); } #[test] - fn test_var_used_in_computed_assignment() { + fn test_removes_unused_in_block() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const obj = {}; - const arr = []; - const key = 'foo'; - const index = 0; - obj[key] = 'bar'; - arr[index] = 'value'; - console.log(obj, arr); + function foo() { + { + const unused = 42; + console.log('hello'); + } + } "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1208,24 +1229,25 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const obj = {}; - const arr = []; - const key = 'foo'; - const index = 0; - obj[key] = 'bar'; - arr[index] = 'value'; - console.log(obj, arr); + function foo() { + { + console.log('hello'); + } + } "#} ); } #[test] - fn test_var_used_in_for_loop_body() { + fn test_unused_after_used_in_arrow() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - let retryDelay = 1000; - for(let i = 0; i < 5; i++)retryDelay *= 2; - console.log(retryDelay); + const inner = () => { + const c = 1; + const unusedInner = c; + console.log(c); + }; + inner(); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1233,19 +1255,22 @@ mod tests { assert_eq!( output_code, indoc! {r#" - let retryDelay = 1000; - for(let i = 0; i < 5; i++)retryDelay *= 2; - console.log(retryDelay); + const inner = ()=>{ + const c = 1; + console.log(c); + }; + inner(); "#} ); } #[test] - fn test_var_used_in_compound_expression() { + fn test_var_used_in_for_loop_body() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; - return v.toString(16); + let retryDelay = 1000; + for(let i = 0; i < 5; i++)retryDelay *= 2; + console.log(retryDelay); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1253,19 +1278,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const r = Math.random() * 16 | 0, v = r & 0x3 | 0x8; - return v.toString(16); + let retryDelay = 1000; + for(let i = 0; i < 5; i++)retryDelay *= 2; + console.log(retryDelay); "#} ); } #[test] - fn test_multiple_declarators() { + fn test_unused_iteration_variable() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const a = 1, b = 2; - const unused = 1, used = 2; - console.log(a, b, used); + for (var foo in []) { + console.log("Hello"); + } "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1273,22 +1299,24 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const a = 1, b = 2; - const used = 2; - console.log(a, b, used); + for(var foo in []){ + console.log("Hello"); + } "#} ); } #[test] - fn test_mutation_operators() { + fn test_keeps_factory_in_iife_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - let count = 0; - let i = 0; - count += 1; - i++; - console.log(count, i); + (function (global, factory) { + module.exports = factory(); + })(this, (function () { + const lottie = {}; + return lottie; + })); + const unused = 1; "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1296,21 +1324,29 @@ mod tests { assert_eq!( output_code, indoc! {r#" - let count = 0; - let i = 0; - count += 1; - i++; - console.log(count, i); + (function(global, factory) { + module.exports = factory(); + })(this, (function() { + const lottie = {}; + return lottie; + })); "#} ); } + // =========================================================================== + // Multi-pass elimination + // =========================================================================== + #[test] - fn test_var_assign_only() { + fn test_multi_pass_removes_cascading_unused_bindings() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - let foo = 0; - foo = 1; + const a = 1; + const b = a; + const c = b; + const d = c; + console.log('hello'); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1318,19 +1354,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - let foo = 0; - foo = 1; + console.log('hello'); "#} ); } #[test] - fn test_unused_iteration_variable() { + fn test_multi_pass_partial_chain() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - for (var foo in []) { - console.log("Hello"); - } + const a = 1; + const b = a; + const c = b; + const d = c; + console.log(b); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1338,62 +1375,39 @@ mod tests { assert_eq!( output_code, indoc! {r#" - for(var foo in []){ - console.log("Hello"); - } + const a = 1; + const b = a; + console.log(b); "#} ); } #[test] - fn test_computed_property_exclusion_with_rest() { + fn test_multi_pass_scopes() { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" - const key = 'excluded'; - const { [key]: _, ...rest } = obj; - console.log(rest); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); + import used from 'used'; + import unused from 'unused'; - assert_eq!( - output_code, - indoc! {r#" - const key = 'excluded'; - const { [key]: _, ...rest } = obj; - console.log(rest); - "#} - ); - } + outer(); - #[test] - fn test_object_rest_vs_no_rest() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const { a, b, ...rest } = obj; - const { c, d, e } = obj2; - console.log(rest, c); - "#}, - |_: RunTestContext| UnusedBindingsRemover::new(), - ); + function outer() { + const a = 1; + const b = a; - assert_eq!( - output_code, - indoc! {r#" - const { a, b, ...rest } = obj; - const { c } = obj2; - console.log(rest, c); - "#} - ); - } + const inner = () => { + { + const unusedBlockInner = 42; + used(); + } - #[test] - fn test_array_destructuring_holes_and_trimming() { - let RunVisitResult { output_code, .. } = run_test_visit( - indoc! {r#" - const [a, b, c] = arr; - const [d, e, f, g] = arr2; - console.log(a, c, d); + const c = b; + const unusedInner = c; + console.log(c); + }; + + inner(); + } "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1401,9 +1415,20 @@ mod tests { assert_eq!( output_code, indoc! {r#" - const [a, , c] = arr; - const [d] = arr2; - console.log(a, c, d); + import used from 'used'; + outer(); + function outer() { + const a = 1; + const b = a; + const inner = ()=>{ + { + used(); + } + const c = b; + console.log(c); + }; + inner(); + } "#} ); } From a946246ad7330b5ca4b4a7ac75a7735fec49e595 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Fri, 31 Oct 2025 12:25:09 +1100 Subject: [PATCH 12/22] Disable import modification to more closely mimic Babel transformer --- packages/transformers/js/core/src/unused_bindings_remover.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 6340069a5..c4daca234 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -129,6 +129,9 @@ impl UnusedBindingsRemover { } fn should_keep_import_spec(&self, spec: &ImportSpecifier) -> bool { + // TODO: Re-implement light import cleaning + return true; + let id = match spec { ImportSpecifier::Named(named) => &named.local, ImportSpecifier::Default(default) => &default.local, From 4328eb14084c3c2ee9712a48bce488482cde2167 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Wed, 5 Nov 2025 15:48:22 +1100 Subject: [PATCH 13/22] Small improvements --- .../js/core/src/unused_bindings_remover.rs | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index c4daca234..c83ba8c57 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -2,34 +2,6 @@ use std::collections::{HashMap, HashSet}; use swc_core::ecma::ast::*; use swc_core::ecma::visit::{VisitMut, VisitMutWith, VisitWith}; -/// Helper function to recursively collect binding identifiers from patterns. -/// Calls the provided closure for each binding found, passing the identifier and export status. -fn collect_bindings_from_pat(pat: &Pat, is_exported: bool, f: &mut F) -where - F: FnMut(Id, bool), -{ - match pat { - Pat::Ident(ident) => f(ident.id.to_id(), is_exported), - Pat::Array(arr) => { - for elem in arr.elems.iter().flatten() { - collect_bindings_from_pat(elem, is_exported, f); - } - } - Pat::Object(obj) => { - for prop in &obj.props { - match prop { - ObjectPatProp::KeyValue(kv) => collect_bindings_from_pat(&kv.value, is_exported, f), - ObjectPatProp::Assign(assign) => f(assign.key.to_id(), is_exported), - ObjectPatProp::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), - } - } - } - Pat::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), - Pat::Assign(assign) => collect_bindings_from_pat(&assign.left, is_exported, f), - _ => {} - } -} - /// Transformer that removes unused variable bindings. /// /// This transform removes variable declarations that are never referenced, @@ -231,6 +203,34 @@ impl UnusedBindingsRemover { } } +/// Helper function to recursively collect binding identifiers from patterns. +/// Calls the provided closure for each binding found, passing the identifier and export status. +fn collect_bindings_from_pat(pat: &Pat, is_exported: bool, f: &mut F) +where + F: FnMut(Id, bool), +{ + match pat { + Pat::Ident(ident) => f(ident.id.to_id(), is_exported), + Pat::Array(arr) => { + for elem in arr.elems.iter().flatten() { + collect_bindings_from_pat(elem, is_exported, f); + } + } + Pat::Object(obj) => { + for prop in &obj.props { + match prop { + ObjectPatProp::KeyValue(kv) => collect_bindings_from_pat(&kv.value, is_exported, f), + ObjectPatProp::Assign(assign) => f(assign.key.to_id(), is_exported), + ObjectPatProp::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), + } + } + } + Pat::Rest(rest) => collect_bindings_from_pat(&rest.arg, is_exported, f), + Pat::Assign(assign) => collect_bindings_from_pat(&assign.left, is_exported, f), + _ => {} + } +} + /// Visitor that collects all variable and import declarations. struct DeclarationCollector<'a> { declared_bindings: &'a mut HashMap, @@ -979,6 +979,25 @@ mod tests { ); } + #[test] + fn test_keeps_chained_cjs_property_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const foo = 1; + var bar = module.exports.default = foo; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const foo = 1; + var bar = module.exports.default = foo; + "#} + ); + } + // =========================================================================== // Variable usage patterns // =========================================================================== @@ -1190,7 +1209,10 @@ mod tests { ); } + // =========================================================================== // Scoped contexts + // =========================================================================== + #[test] fn test_removes_unused_in_function() { let RunVisitResult { output_code, .. } = run_test_visit( From 62f841e0790bc95f9c8a8dfdbbf4c0fdaa7e3fbb Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Thu, 6 Nov 2025 12:10:35 +1100 Subject: [PATCH 14/22] Fix chained CJS exports being removed --- .../js/core/src/unused_bindings_remover.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index c83ba8c57..e1f2c28ff 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -304,9 +304,39 @@ impl BindingCollector<'_> { self.used_bindings.insert(id); } } + + /// Checks if a member expression is a CommonJS export pattern. + /// Returns true for patterns like: `module.exports.*`, `exports.*` + fn is_cjs_export_member(&self, member: &MemberExpr) -> bool { + match &*member.obj { + // Pattern: module.exports.* + Expr::Member(inner_member) => matches!( + (&*inner_member.obj, &inner_member.prop), + (Expr::Ident(obj), MemberProp::Ident(prop)) + if &*obj.sym == "module" && &*prop.sym == "exports" + ), + // Pattern: exports.* + Expr::Ident(ident) => &*ident.sym == "exports", + _ => false, + } + } } impl swc_core::ecma::visit::Visit for BindingCollector<'_> { + // Visit variable declarators to check for CJS export assignments in initializers + fn visit_var_declarator(&mut self, declarator: &VarDeclarator) { + // If initialized with a CJS export assignment (e.g., var bar = module.exports.x = foo), + // mark the variable as used since it captures the result of an export with side effects + if let Some(Expr::Assign(assign)) = declarator.init.as_deref() { + if let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assign.left { + if self.is_cjs_export_member(member) { + self.mark_bindings_in_pat_as_used(&declarator.name); + } + } + } + declarator.visit_children_with(self); + } + // Visit expressions to find identifier references fn visit_expr(&mut self, expr: &Expr) { if let Expr::Ident(ident) = expr { @@ -363,6 +393,12 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Handle assignment expressions - visit both sides appropriately fn visit_assign_expr(&mut self, assign: &AssignExpr) { + // Check if this is a CommonJS export pattern: module.exports.* = value or exports.* = value + let is_cjs_export = match &assign.left { + AssignTarget::Simple(SimpleAssignTarget::Member(member)) => self.is_cjs_export_member(member), + _ => false, + }; + match &assign.left { // For simple identifier assignments like foo = 1, mark the identifier as used AssignTarget::Simple(SimpleAssignTarget::Ident(ident)) => { @@ -378,7 +414,14 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { assign.left.visit_with(self); } } + // Visit right side + // If this is a CJS export, mark any identifiers on the right as used (exported) + if is_cjs_export { + if let Expr::Ident(ident) = &*assign.right { + self.used_bindings.insert(ident.to_id()); + } + } assign.right.visit_with(self); } From a9d78505f692c7eec92f73f40e971cd564926386 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Thu, 6 Nov 2025 12:57:33 +1100 Subject: [PATCH 15/22] Small improvements --- .../js/core/src/unused_bindings_remover.rs | 93 +++++++------------ 1 file changed, 35 insertions(+), 58 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index e1f2c28ff..40a599f5e 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -100,21 +100,11 @@ impl UnusedBindingsRemover { } } - fn should_keep_import_spec(&self, spec: &ImportSpecifier) -> bool { - // TODO: Re-implement light import cleaning - return true; - - let id = match spec { - ImportSpecifier::Named(named) => &named.local, - ImportSpecifier::Default(default) => &default.local, - ImportSpecifier::Namespace(ns) => &ns.local, - }; - self.should_keep_binding(&id.to_id(), false) - } - /// Runs multiple passes of unused binding elimination until no more progress can be made. /// Each pass collects declarations, collects usages, then removes unused bindings. fn run_elimination_passes(&mut self, module: &mut Module) { + let mut prev_declaration_count = usize::MAX; + loop { self.declared_bindings.clear(); self.used_bindings.clear(); @@ -122,12 +112,13 @@ impl UnusedBindingsRemover { // Collect declarations module.visit_with(&mut DeclarationCollector::new(&mut self.declared_bindings)); - if self.declared_bindings.is_empty() { + let current_declaration_count = self.declared_bindings.len(); + + // Exit if no declarations or no progress was made + if current_declaration_count == 0 || current_declaration_count == prev_declaration_count { break; } - let declarations_before = self.declared_bindings.len(); - // Collect usages module.visit_with(&mut BindingCollector::new( &mut self.used_bindings, @@ -140,35 +131,28 @@ impl UnusedBindingsRemover { // Clean up empty declarations and imports self.cleanup_module_items(&mut module.body); - // Check if we made progress - self.declared_bindings.clear(); - module.visit_with(&mut DeclarationCollector::new(&mut self.declared_bindings)); - - if self.declared_bindings.len() == declarations_before { - break; - } + prev_declaration_count = current_declaration_count; } } fn remove_from_pat(&self, pat: &mut Pat) { match pat { Pat::Object(obj) => { - let mut has_rest = false; - - // Recursively process nested patterns - for prop in &mut obj.props { - match prop { - ObjectPatProp::KeyValue(kv) => self.remove_from_pat(&mut kv.value), - ObjectPatProp::Rest(rest) => { - has_rest = true; - self.remove_from_pat(&mut rest.arg); - } - _ => {} + // Recursively process nested patterns and check for rest + let has_rest = obj.props.iter_mut().any(|prop| match prop { + ObjectPatProp::KeyValue(kv) => { + self.remove_from_pat(&mut kv.value); + false } - } + ObjectPatProp::Rest(rest) => { + self.remove_from_pat(&mut rest.arg); + true + } + _ => false, + }); + // Don't remove properties if rest pattern exists (affects rest contents) if has_rest { - // Don't remove any properties to not affect the rest pattern return; } @@ -194,7 +178,7 @@ impl UnusedBindingsRemover { } // Trim trailing holes to avoid unnecessary commas like [a, , , ] - while arr.elems.last().is_some_and(|e| e.is_none()) { + while matches!(arr.elems.last(), Some(None)) { arr.elems.pop(); } } @@ -309,14 +293,14 @@ impl BindingCollector<'_> { /// Returns true for patterns like: `module.exports.*`, `exports.*` fn is_cjs_export_member(&self, member: &MemberExpr) -> bool { match &*member.obj { + // Pattern: exports.* + Expr::Ident(ident) if &*ident.sym == "exports" => true, // Pattern: module.exports.* - Expr::Member(inner_member) => matches!( - (&*inner_member.obj, &inner_member.prop), + Expr::Member(inner) => matches!( + (&*inner.obj, &inner.prop), (Expr::Ident(obj), MemberProp::Ident(prop)) if &*obj.sym == "module" && &*prop.sym == "exports" ), - // Pattern: exports.* - Expr::Ident(ident) => &*ident.sym == "exports", _ => false, } } @@ -327,12 +311,11 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { fn visit_var_declarator(&mut self, declarator: &VarDeclarator) { // If initialized with a CJS export assignment (e.g., var bar = module.exports.x = foo), // mark the variable as used since it captures the result of an export with side effects - if let Some(Expr::Assign(assign)) = declarator.init.as_deref() { - if let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assign.left { - if self.is_cjs_export_member(member) { - self.mark_bindings_in_pat_as_used(&declarator.name); - } - } + if let Some(Expr::Assign(assign)) = declarator.init.as_deref() + && let AssignTarget::Simple(SimpleAssignTarget::Member(member)) = &assign.left + && self.is_cjs_export_member(member) + { + self.mark_bindings_in_pat_as_used(&declarator.name); } declarator.visit_children_with(self); } @@ -417,10 +400,8 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { // Visit right side // If this is a CJS export, mark any identifiers on the right as used (exported) - if is_cjs_export { - if let Expr::Ident(ident) = &*assign.right { - self.used_bindings.insert(ident.to_id()); - } + if is_cjs_export && let Expr::Ident(ident) = &*assign.right { + self.used_bindings.insert(ident.to_id()); } assign.right.visit_with(self); } @@ -504,20 +485,17 @@ impl VisitMut for UnusedBindingsRemover { self.cleanup_empty_var_decls(stmts); } - fn visit_mut_import_decl(&mut self, import: &mut ImportDecl) { - import - .specifiers - .retain(|spec| self.should_keep_import_spec(spec)); - } + // Import cleaning is currently disabled (always keeps all imports) + // TODO: Re-implement light import cleaning if needed fn visit_mut_for_in_stmt(&mut self, node: &mut ForInStmt) { - // Don't remove the loop variable, only visit the body + // Never visit the loop variable (node.left) since it is always necessary node.right.visit_mut_with(self); node.body.visit_mut_with(self); } fn visit_mut_for_of_stmt(&mut self, node: &mut ForOfStmt) { - // Don't remove the loop variable, only visit the body + // Never visit the loop variable (node.left) since it is always necessary node.right.visit_mut_with(self); node.body.visit_mut_with(self); } @@ -1455,7 +1433,6 @@ mod tests { let RunVisitResult { output_code, .. } = run_test_visit( indoc! {r#" import used from 'used'; - import unused from 'unused'; outer(); From 743ba5583a10a8dcbe16752bfa1b5fc1493bd1a5 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Fri, 7 Nov 2025 13:35:56 +1100 Subject: [PATCH 16/22] Re-implement import cleaning but fixed --- .../js/core/src/unused_bindings_remover.rs | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 40a599f5e..a39a93013 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -99,6 +99,14 @@ impl UnusedBindingsRemover { _ => true, } } + fn should_keep_import_spec(&self, spec: &ImportSpecifier) -> bool { + let id = match spec { + ImportSpecifier::Named(named) => &named.local, + ImportSpecifier::Default(default) => &default.local, + ImportSpecifier::Namespace(ns) => &ns.local, + }; + self.should_keep_binding(&id.to_id(), false) + } /// Runs multiple passes of unused binding elimination until no more progress can be made. /// Each pass collects declarations, collects usages, then removes unused bindings. @@ -416,12 +424,19 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { } } + // Visit function parameters to explicitly handle default values in patterns + fn visit_param(&mut self, param: &Param) { + self.visit_pat(¶m.pat); + } + // Don't visit patterns (they're declarations, not references) // But we need to visit default values in patterns fn visit_pat(&mut self, pat: &Pat) { match pat { Pat::Assign(assign) => { - // Visit the default value (right side) to collect any usages within it + // First, visit the left side to handle nested patterns with defaults + self.visit_pat(&assign.left); + // Then visit the default value (right side) to collect any usages within it assign.right.visit_with(self); } Pat::Object(obj) => { @@ -433,6 +448,10 @@ impl swc_core::ecma::visit::Visit for BindingCollector<'_> { if let PropName::Computed(computed) = &kv.key { computed.expr.visit_with(self); } + // Visit default values in nested patterns like { b: b = foo } + if let Pat::Assign(assign) = &*kv.value { + assign.right.visit_with(self); + } } ObjectPatProp::Assign(assign) => { // Visit default values like { foo = defaultValue } @@ -485,8 +504,11 @@ impl VisitMut for UnusedBindingsRemover { self.cleanup_empty_var_decls(stmts); } - // Import cleaning is currently disabled (always keeps all imports) - // TODO: Re-implement light import cleaning if needed + fn visit_mut_import_decl(&mut self, import: &mut ImportDecl) { + import + .specifiers + .retain(|spec| self.should_keep_import_spec(spec)); + } fn visit_mut_for_in_stmt(&mut self, node: &mut ForInStmt) { // Never visit the loop variable (node.left) since it is always necessary @@ -674,6 +696,58 @@ mod tests { ); } + #[test] + fn test_handles_destructuring_alias_default() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import { foo } from "foo"; + const { a, b: b = foo } = obj; + console.log(a, b); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import { foo } from "foo"; + const { a, b: b = foo } = obj; + console.log(a, b); + "#} + ); + } + + #[test] + fn test_handles_destructuring_alias_default_constructor() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import { foo } from "foo"; + class Foo { + constructor({ a, b: b = foo } = {}) { + this.a = a; + this.b = b; + } + } + console.log(new Foo()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import { foo } from "foo"; + class Foo { + constructor({ a, b: b = foo } = {}){ + this.a = a; + this.b = b; + } + } + console.log(new Foo()); + "#} + ); + } + #[test] fn test_object_rest_pattern() { let RunVisitResult { output_code, .. } = run_test_visit( From 247e52a42df48ed44fe96d8a09f8c9fcd1ba9acd Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 10 Nov 2025 16:22:08 +1100 Subject: [PATCH 17/22] Slight cleanup of wonky should keep function --- .../transformers/js/core/src/unused_bindings_remover.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index a39a93013..9e38c419b 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -58,8 +58,8 @@ impl UnusedBindingsRemover { matches!(name, "di" | "jsx" | "React") } - fn should_keep_binding(&self, id: &Id, is_exported: bool) -> bool { - self.used_bindings.contains(id) || Self::is_special_ident(&id.0) || is_exported + fn should_keep_binding(&self, id: &Id) -> bool { + self.used_bindings.contains(id) || Self::is_special_ident(&id.0) } fn is_pattern_empty(&self, pat: &Pat) -> bool { @@ -94,7 +94,7 @@ impl UnusedBindingsRemover { self .declared_bindings .get(&id) - .is_none_or(|&is_exported| self.should_keep_binding(&id, is_exported)) + .is_none_or(|&is_exported| self.should_keep_binding(&id) || is_exported) } _ => true, } @@ -105,7 +105,7 @@ impl UnusedBindingsRemover { ImportSpecifier::Default(default) => &default.local, ImportSpecifier::Namespace(ns) => &ns.local, }; - self.should_keep_binding(&id.to_id(), false) + self.should_keep_binding(&id.to_id()) } /// Runs multiple passes of unused binding elimination until no more progress can be made. From 5e615ff99d9872ea63b3c51e2436c1384b8e0624 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Wed, 12 Nov 2025 15:39:35 +1100 Subject: [PATCH 18/22] Fix special imports being removed, tests to follow --- .../transformers/js/core/src/unused_bindings_remover.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 9e38c419b..78a2d0904 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -58,6 +58,10 @@ impl UnusedBindingsRemover { matches!(name, "di" | "jsx" | "React") } + fn is_special_import(src: &str) -> bool { + src.contains("rxjs") || matches!(src, "react" | "@emotion/react" | "@atlaskit/css-reset") + } + fn should_keep_binding(&self, id: &Id) -> bool { self.used_bindings.contains(id) || Self::is_special_ident(&id.0) } @@ -78,7 +82,9 @@ impl UnusedBindingsRemover { fn cleanup_module_items(&self, items: &mut Vec) { items.retain(|item| match item { ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) => !var.decls.is_empty(), - ModuleItem::ModuleDecl(ModuleDecl::Import(import)) => !import.specifiers.is_empty(), + ModuleItem::ModuleDecl(ModuleDecl::Import(import)) => { + !import.specifiers.is_empty() || Self::is_special_import(&import.src.value) + } _ => true, }); } From a653244c19a99b11627e5d2961be4a8850ec6c35 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Wed, 12 Nov 2025 16:56:32 +1100 Subject: [PATCH 19/22] Fix modules without bindings not being cleared Add import cleaning tests --- .../js/core/src/unused_bindings_remover.rs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 78a2d0904..56c0f21f2 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -147,6 +147,9 @@ impl UnusedBindingsRemover { prev_declaration_count = current_declaration_count; } + + // Clean up empty declarations and imports + self.cleanup_module_items(&mut module.body); } fn remove_from_pat(&self, pat: &mut Pat) { @@ -922,6 +925,177 @@ mod tests { ); } + // =========================================================================== + // Imports + // =========================================================================== + + #[test] + fn test_retains_special_imports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import 'rxjs'; + import 'react'; + import '@emotion/react'; + import '@atlaskit/css-reset'; + import 'foo'; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import 'rxjs'; + import 'react'; + import '@emotion/react'; + import '@atlaskit/css-reset'; + "#} + ); + } + + #[test] + fn test_removes_unused_named_imports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import { used1, unused1, unused2 } from 'foo'; + import { unused3, used2 } from 'bar'; + console.log(used1, used2); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import { used1 } from 'foo'; + import { used2 } from 'bar'; + console.log(used1, used2); + "#} + ); + } + + #[test] + fn test_removes_unused_default_and_namespace_imports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import React from 'react'; + import * as utils from 'utils'; + import { useState } from 'other-module'; + const unused = 1; + console.log(React, utils); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import React from 'react'; + import * as utils from 'utils'; + console.log(React, utils); + "#} + ); + } + + #[test] + fn test_removes_entire_imports_when_all_unused() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import { a, b } from 'unused-module'; + import { c } from 'also-unused'; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!(output_code, indoc! {""}); + } + + #[test] + fn test_handles_mixed_import_syntax_with_requires() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const fs = require('fs'); + const path = require('path'); + import lodash from 'lodash'; + const unused = require('unused'); + console.log(fs, path, lodash); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const fs = require('fs'); + const path = require('path'); + import lodash from 'lodash'; + console.log(fs, path, lodash); + "#} + ); + } + + #[test] + fn test_handles_destructured_requires_and_imports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const { readFile, writeFile, unused } = require('fs'); + import { useState, useRef } from 'react'; + const { notUsed } = require('other'); + console.log(readFile, useState); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const { readFile } = require('fs'); + import { useState } from 'react'; + console.log(readFile, useState); + "#} + ); + } + + #[test] + fn test_keeps_imports_used_in_default_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import Component from './Component'; + import unused from 'unused'; + export default Component; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import Component from './Component'; + export default Component; + "#} + ); + } + + #[test] + fn test_keeps_imports_used_in_named_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + import { foo, bar } from 'module'; + import { unused } from 'other'; + export { foo, bar }; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + import { foo, bar } from 'module'; + export { foo, bar }; + "#} + ); + } + // =========================================================================== // Exports // =========================================================================== From 51e046f6f49ee447c045f1c47a9a0cea571542d2 Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Mon, 17 Nov 2025 15:03:09 +1100 Subject: [PATCH 20/22] Add changeset --- .changeset/cold-plums-eat.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cold-plums-eat.md diff --git a/.changeset/cold-plums-eat.md b/.changeset/cold-plums-eat.md new file mode 100644 index 000000000..9a4b218a9 --- /dev/null +++ b/.changeset/cold-plums-eat.md @@ -0,0 +1,7 @@ +--- +'@atlaspack/transformer-js': minor +'@atlaspack/rust': minor +--- + +- Implement new dead_returns_remover transformer and hook up via opt-in flag +- Implement new unused_bindings_remover transformer and hook up via opt-in flag From 39569e38c06eb41d5df456b9da129f35c029e08b Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Tue, 18 Nov 2025 12:48:52 +1100 Subject: [PATCH 21/22] Fix flag parsing --- packages/transformers/js/src/JSTransformer.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/transformers/js/src/JSTransformer.ts b/packages/transformers/js/src/JSTransformer.ts index 045499c37..4dd23f75d 100644 --- a/packages/transformers/js/src/JSTransformer.ts +++ b/packages/transformers/js/src/JSTransformer.ts @@ -302,18 +302,14 @@ export default new Transformer({ let magicComments = false; let addReactDisplayName = false; - let enableGlobalThisAliaser = Boolean( - options.env.NATIVE_GLOBAL_THIS_ALIASER, - ); - let enableLazyLoadingTransformer = Boolean( - options.env.NATIVE_LAZY_LOADING_TRANSFORMER, - ); - let enableDeadReturnsRemover = Boolean( - options.env.NATIVE_DEAD_RETURNS_REMOVER, - ); - let enableUnusedBindingsRemover = Boolean( - options.env.NATIVE_UNUSED_BINDINGS_REMOVER, - ); + let enableGlobalThisAliaser = + options.env.NATIVE_GLOBAL_THIS_ALIASER === 'true'; + let enableLazyLoadingTransformer = + options.env.NATIVE_LAZY_LOADING_TRANSFORMER === 'true'; + let enableDeadReturnsRemover = + options.env.NATIVE_DEAD_RETURNS_REMOVER === 'true'; + let enableUnusedBindingsRemover = + options.env.NATIVE_UNUSED_BINDINGS_REMOVER === 'true'; if (conf && conf.contents) { validateSchema.diagnostic( From 0851889dc67c0fc7c2fd022e0e0faef3fad1b71f Mon Sep 17 00:00:00 2001 From: Oscar Cooke-Abbott Date: Tue, 18 Nov 2025 17:39:56 +1100 Subject: [PATCH 22/22] Add unused function cleaning --- .../js/core/src/unused_bindings_remover.rs | 427 +++++++++++++++++- 1 file changed, 416 insertions(+), 11 deletions(-) diff --git a/packages/transformers/js/core/src/unused_bindings_remover.rs b/packages/transformers/js/core/src/unused_bindings_remover.rs index 56c0f21f2..2b196cdf5 100644 --- a/packages/transformers/js/core/src/unused_bindings_remover.rs +++ b/packages/transformers/js/core/src/unused_bindings_remover.rs @@ -2,14 +2,15 @@ use std::collections::{HashMap, HashSet}; use swc_core::ecma::ast::*; use swc_core::ecma::visit::{VisitMut, VisitMutWith, VisitWith}; -/// Transformer that removes unused variable bindings. +/// Transformer that removes unused variable bindings and function declarations. /// -/// This transform removes variable declarations that are never referenced, +/// This transform removes variable declarations and function declarations that are never referenced, /// helping to clean up dead code. It uses a multi-pass algorithm to handle /// cascading dependencies (e.g., `const a = 1; const b = a;` where neither is used). /// /// It handles: /// - Simple variable declarations +/// - Function declarations (including async and generator functions) /// - Object destructuring patterns /// - Array destructuring patterns /// - Special cases like `di()` calls and exports @@ -82,6 +83,7 @@ impl UnusedBindingsRemover { fn cleanup_module_items(&self, items: &mut Vec) { items.retain(|item| match item { ModuleItem::Stmt(Stmt::Decl(Decl::Var(var))) => !var.decls.is_empty(), + ModuleItem::Stmt(Stmt::Decl(Decl::Fn(func))) => self.should_keep_function(func), ModuleItem::ModuleDecl(ModuleDecl::Import(import)) => { !import.specifiers.is_empty() || Self::is_special_import(&import.src.value) } @@ -89,8 +91,12 @@ impl UnusedBindingsRemover { }); } - fn cleanup_empty_var_decls(&self, stmts: &mut Vec) { - stmts.retain(|stmt| !matches!(stmt, Stmt::Decl(Decl::Var(var)) if var.decls.is_empty())); + fn cleanup_unused_statements(&self, stmts: &mut Vec) { + stmts.retain(|stmt| match stmt { + Stmt::Decl(Decl::Var(var)) => !var.decls.is_empty(), + Stmt::Decl(Decl::Fn(func)) => self.should_keep_function(func), + _ => true, + }); } fn should_keep_declarator(&self, decl: &VarDeclarator) -> bool { @@ -114,6 +120,14 @@ impl UnusedBindingsRemover { self.should_keep_binding(&id.to_id()) } + fn should_keep_function(&self, func: &FnDecl) -> bool { + let id = func.ident.to_id(); + self + .declared_bindings + .get(&id) + .is_none_or(|&is_exported| self.should_keep_binding(&id) || is_exported) + } + /// Runs multiple passes of unused binding elimination until no more progress can be made. /// Each pass collects declarations, collects usages, then removes unused bindings. fn run_elimination_passes(&mut self, module: &mut Module) { @@ -148,7 +162,18 @@ impl UnusedBindingsRemover { prev_declaration_count = current_declaration_count; } - // Clean up empty declarations and imports + // After the loop exits, declared_bindings has been collected but used_bindings hasn't. + // We need to collect usages one more time before the final cleanup to ensure + // we don't remove items that are actually used. + if !self.declared_bindings.is_empty() { + self.used_bindings.clear(); + module.visit_with(&mut BindingCollector::new( + &mut self.used_bindings, + &self.declared_bindings, + )); + } + + // Final cleanup to handle any remaining empty imports or declarations self.cleanup_module_items(&mut module.body); } @@ -253,12 +278,25 @@ impl swc_core::ecma::visit::Visit for DeclarationCollector<'_> { var.visit_children_with(self); } - // Collect exported variable declarations + // Collect function declarations + fn visit_fn_decl(&mut self, func: &FnDecl) { + self.declared_bindings.insert(func.ident.to_id(), false); + // Continue visiting to find nested declarations inside function bodies + func.visit_children_with(self); + } + + // Collect exported variable and function declarations fn visit_export_decl(&mut self, export: &ExportDecl) { - if let Decl::Var(var) = &export.decl { - for declarator in &var.decls { - self.collect_bindings_from_pat(&declarator.name, true); + match &export.decl { + Decl::Var(var) => { + for declarator in &var.decls { + self.collect_bindings_from_pat(&declarator.name, true); + } } + Decl::Fn(func) => { + self.declared_bindings.insert(func.ident.to_id(), true); + } + _ => {} } // Continue visiting to find nested var decls export.visit_children_with(self); @@ -505,12 +543,12 @@ impl VisitMut for UnusedBindingsRemover { fn visit_mut_block_stmt(&mut self, block: &mut BlockStmt) { block.visit_mut_children_with(self); - self.cleanup_empty_var_decls(&mut block.stmts); + self.cleanup_unused_statements(&mut block.stmts); } fn visit_mut_stmts(&mut self, stmts: &mut Vec) { stmts.visit_mut_children_with(self); - self.cleanup_empty_var_decls(stmts); + self.cleanup_unused_statements(stmts); } fn visit_mut_import_decl(&mut self, import: &mut ImportDecl) { @@ -1285,6 +1323,7 @@ mod tests { function foo() { return x; } + foo(); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1296,6 +1335,7 @@ mod tests { function foo() { return x; } + foo(); "#} ); } @@ -1497,6 +1537,7 @@ mod tests { const used = 2; console.log(used); } + foo(); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1508,6 +1549,7 @@ mod tests { const used = 2; console.log(used); } + foo(); "#} ); } @@ -1522,6 +1564,7 @@ mod tests { console.log('hello'); } } + foo(); "#}, |_: RunTestContext| UnusedBindingsRemover::new(), ); @@ -1534,6 +1577,7 @@ mod tests { console.log('hello'); } } + foo(); "#} ); } @@ -1731,4 +1775,365 @@ mod tests { "#} ); } + + // =========================================================================== + // Function declarations + // =========================================================================== + + #[test] + fn test_removes_unused_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function unused() { + return 42; + } + function used() { + return 1; + } + console.log(used()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function used() { + return 1; + } + console.log(used()); + "#} + ); + } + + #[test] + fn test_keeps_all_used_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function foo() { + return 1; + } + function bar() { + return 2; + } + console.log(foo(), bar()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function foo() { + return 1; + } + function bar() { + return 2; + } + console.log(foo(), bar()); + "#} + ); + } + + #[test] + fn test_removes_unused_function_with_nested_declarations() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function unused() { + const x = 1; + const y = 2; + return x + y; + } + function used() { + return 42; + } + console.log(used()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function used() { + return 42; + } + console.log(used()); + "#} + ); + } + + #[test] + fn test_keeps_function_used_in_another_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function helper() { + return 42; + } + function main() { + return helper(); + } + console.log(main()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function helper() { + return 42; + } + function main() { + return helper(); + } + console.log(main()); + "#} + ); + } + + #[test] + fn test_multi_pass_removes_cascading_unused_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function a() { + return 1; + } + function b() { + return a(); + } + function c() { + return b(); + } + console.log('hello'); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + console.log('hello'); + "#} + ); + } + + #[test] + fn test_keeps_exported_functions() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + export function exported() { + return 1; + } + function unused() { + return 2; + } + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + export function exported() { + return 1; + } + "#} + ); + } + + #[test] + fn test_keeps_default_exported_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function used() { + return 1; + } + function unused() { + return 2; + } + export default used; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function used() { + return 1; + } + export default used; + "#} + ); + } + + #[test] + fn test_keeps_function_used_in_object_property() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function handler() { + return 42; + } + function unused() { + return 1; + } + const obj = { onClick: handler }; + console.log(obj); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function handler() { + return 42; + } + const obj = { + onClick: handler + }; + console.log(obj); + "#} + ); + } + + #[test] + fn test_removes_unused_nested_function() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function outer() { + function unusedNested() { + return 1; + } + function usedNested() { + return 2; + } + return usedNested(); + } + outer(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function outer() { + function usedNested() { + return 2; + } + return usedNested(); + } + outer(); + "#} + ); + } + + #[test] + fn test_keeps_function_in_cjs_exports() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function exported() { + return 1; + } + function unused() { + return 2; + } + module.exports = exported; + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function exported() { + return 1; + } + module.exports = exported; + "#} + ); + } + + #[test] + fn test_mixed_functions_and_variables() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + const unusedVar = 1; + function unusedFn() { + return 2; + } + const usedVar = 3; + function usedFn() { + return usedVar; + } + console.log(usedFn()); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + const usedVar = 3; + function usedFn() { + return usedVar; + } + console.log(usedFn()); + "#} + ); + } + + #[test] + fn test_keeps_async_function_when_used() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + async function fetchData() { + return await Promise.resolve(42); + } + async function unusedAsync() { + return 1; + } + fetchData(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + async function fetchData() { + return await Promise.resolve(42); + } + fetchData(); + "#} + ); + } + + #[test] + fn test_keeps_generator_function_when_used() { + let RunVisitResult { output_code, .. } = run_test_visit( + indoc! {r#" + function* gen() { + yield 1; + } + function* unusedGen() { + yield 2; + } + gen().next(); + "#}, + |_: RunTestContext| UnusedBindingsRemover::new(), + ); + + assert_eq!( + output_code, + indoc! {r#" + function* gen() { + yield 1; + } + gen().next(); + "#} + ); + } }