diff --git a/.changeset/lazy-weeks-allow.md b/.changeset/lazy-weeks-allow.md new file mode 100644 index 000000000000..467505bc5c99 --- /dev/null +++ b/.changeset/lazy-weeks-allow.md @@ -0,0 +1,6 @@ +--- +swc_ecma_minifier: patch +swc_core: patch +--- + +feat(es/minifier): Add merge_imports optimization pass to reduce bundle size diff --git a/crates/swc/tests/tsc-references/es6modulekindWithES5Target9.2.minified.js b/crates/swc/tests/tsc-references/es6modulekindWithES5Target9.2.minified.js index 0af070420531..0718b59bb1b4 100644 --- a/crates/swc/tests/tsc-references/es6modulekindWithES5Target9.2.minified.js +++ b/crates/swc/tests/tsc-references/es6modulekindWithES5Target9.2.minified.js @@ -1,8 +1,7 @@ //// [es6modulekindWithES5Target9.ts] -import d from "mod"; -import { a } from "mod"; -import * as M from "mod"; export * from "mod"; export { b } from "mod"; export default d; +import d, { a } from "mod"; +import * as M from "mod"; export { a, M, d }; diff --git a/crates/swc/tests/tsc-references/esnextmodulekindWithES5Target9.2.minified.js b/crates/swc/tests/tsc-references/esnextmodulekindWithES5Target9.2.minified.js index 7d4363d9af84..cae21b6a9918 100644 --- a/crates/swc/tests/tsc-references/esnextmodulekindWithES5Target9.2.minified.js +++ b/crates/swc/tests/tsc-references/esnextmodulekindWithES5Target9.2.minified.js @@ -1,8 +1,7 @@ //// [esnextmodulekindWithES5Target9.ts] -import d from "mod"; -import { a } from "mod"; -import * as M from "mod"; export * from "mod"; export { b } from "mod"; export default d; +import d, { a } from "mod"; +import * as M from "mod"; export { a, M, d }; diff --git a/crates/swc/tests/tsc-references/exportNamespace12.2.minified.js b/crates/swc/tests/tsc-references/exportNamespace12.2.minified.js index e52823de6318..8bb0418b1cf9 100644 --- a/crates/swc/tests/tsc-references/exportNamespace12.2.minified.js +++ b/crates/swc/tests/tsc-references/exportNamespace12.2.minified.js @@ -1,7 +1,7 @@ //// [main.ts] -import { c } from './types'; -import * as types from './types'; console.log(c), console.log(types.c); +import * as types from "./types"; +import { c } from "./types"; //// [types.ts] export { }; //// [values.ts] diff --git a/crates/swc_ecma_minifier/src/option/mod.rs b/crates/swc_ecma_minifier/src/option/mod.rs index 2099a6f2aed7..1203f825989f 100644 --- a/crates/swc_ecma_minifier/src/option/mod.rs +++ b/crates/swc_ecma_minifier/src/option/mod.rs @@ -282,6 +282,10 @@ pub struct CompressOptions { #[cfg_attr(feature = "extra-serde", serde(alias = "loops"))] pub loops: bool, + #[cfg_attr(feature = "extra-serde", serde(default = "true_by_default"))] + #[cfg_attr(feature = "extra-serde", serde(alias = "merge_imports"))] + pub merge_imports: bool, + #[cfg_attr(feature = "extra-serde", serde(default))] pub module: bool, @@ -451,6 +455,7 @@ impl Default for CompressOptions { keep_fnames: false, keep_infinity: false, loops: true, + merge_imports: true, module: false, negate_iife: true, passes: default_passes(), diff --git a/crates/swc_ecma_minifier/src/option/terser.rs b/crates/swc_ecma_minifier/src/option/terser.rs index 73a57583076c..e0a4131a0989 100644 --- a/crates/swc_ecma_minifier/src/option/terser.rs +++ b/crates/swc_ecma_minifier/src/option/terser.rs @@ -343,6 +343,7 @@ impl TerserCompressorOptions { keep_fnames: self.keep_fnames, keep_infinity: self.keep_infinity, loops: self.loops.unwrap_or(self.defaults), + merge_imports: self.defaults, module: self.module, negate_iife: self.negate_iife.unwrap_or(self.defaults), passes: self.passes, diff --git a/crates/swc_ecma_minifier/src/pass/postcompress.rs b/crates/swc_ecma_minifier/src/pass/postcompress.rs index b4c06e85eec4..d36cb804c148 100644 --- a/crates/swc_ecma_minifier/src/pass/postcompress.rs +++ b/crates/swc_ecma_minifier/src/pass/postcompress.rs @@ -1,4 +1,5 @@ -use swc_common::util::take::Take; +use rustc_hash::FxHashMap; +use swc_common::{util::take::Take, DUMMY_SP}; use swc_ecma_ast::*; use crate::option::CompressOptions; @@ -53,4 +54,294 @@ pub fn postcompress_optimizer(program: &mut Program, options: &CompressOptions) } } } + + // Merge duplicate imports if enabled + if options.merge_imports { + merge_imports_in_module(module); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ImportKey { + src: String, + type_only: bool, + phase: ImportPhase, + /// Hash of the `with` clause to group imports with the same assertions + with_hash: Option, +} + +impl ImportKey { + fn from_import_decl(decl: &ImportDecl) -> Self { + use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + }; + + let with_hash = decl.with.as_ref().map(|w| { + let mut hasher = DefaultHasher::new(); + // Hash the with clause structure + format!("{w:?}").hash(&mut hasher); + hasher.finish() + }); + + Self { + src: decl.src.value.to_string(), + type_only: decl.type_only, + phase: decl.phase, + with_hash, + } + } +} + +/// Key to identify unique import specifiers. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum SpecifierKey { + /// Named import: (imported name, local name, is_type_only) + Named(String, String, bool), + /// Default import: (local name) + Default(String), + /// Namespace import: (local name) + Namespace(String), +} + +impl SpecifierKey { + fn from_specifier(spec: &ImportSpecifier) -> Self { + match spec { + ImportSpecifier::Named(named) => { + let imported = named + .imported + .as_ref() + .map(|n| match n { + ModuleExportName::Ident(id) => id.sym.to_string(), + ModuleExportName::Str(s) => s.value.to_string(), + }) + .unwrap_or_else(|| named.local.sym.to_string()); + + SpecifierKey::Named(imported, named.local.sym.to_string(), named.is_type_only) + } + ImportSpecifier::Default(default) => { + SpecifierKey::Default(default.local.sym.to_string()) + } + ImportSpecifier::Namespace(ns) => SpecifierKey::Namespace(ns.local.sym.to_string()), + } + } +} + +/// Merge duplicate import statements from the same module source. +/// +/// This optimization reduces bundle size by combining multiple imports from +/// the same source into a single import declaration. +fn merge_imports_in_module(module: &mut Module) { + // Group imports by source and metadata + let mut import_groups: FxHashMap> = FxHashMap::default(); + + for item in module.body.iter() { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item { + // Skip side-effect only imports (no specifiers) + if import_decl.specifiers.is_empty() { + continue; + } + + let key = ImportKey::from_import_decl(import_decl); + import_groups + .entry(key) + .or_default() + .push(import_decl.clone()); + } + } + + // Remove all imports that will be merged (except side-effect imports) + module.body.retain(|item| { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item { + // Keep side-effect imports + if import_decl.specifiers.is_empty() { + return true; + } + + let key = ImportKey::from_import_decl(import_decl); + // Only keep if there's just one import for this key (no merging needed) + import_groups.get(&key).map_or(true, |v| v.len() <= 1) + } else { + true + } + }); + + // Create merged imports and add them back + for (key, import_decls) in import_groups.iter() { + if import_decls.len() <= 1 { + // No merging needed, already retained above + continue; + } + + let merged_imports = merge_import_decls(import_decls, key); + for merged in merged_imports { + module + .body + .push(ModuleItem::ModuleDecl(ModuleDecl::Import(merged))); + } + } +} + +/// Merge multiple ImportDecl nodes. +/// Returns a Vec because in some cases (namespace + named), we need to create +/// multiple import statements since they cannot be combined in valid ES syntax. +fn merge_import_decls(decls: &[ImportDecl], key: &ImportKey) -> Vec { + let mut default_spec: Option = None; + let mut namespace_spec: Option = None; + let mut named_specs: Vec = Vec::new(); + let mut seen_named: FxHashMap = FxHashMap::default(); + + let first_decl = &decls[0]; + let span = first_decl.span; + + // Separate specifiers by type + for decl in decls { + for spec in &decl.specifiers { + match spec { + ImportSpecifier::Default(_) => { + if default_spec.is_none() { + default_spec = Some(spec.clone()); + } + } + ImportSpecifier::Namespace(_) => { + if namespace_spec.is_none() { + namespace_spec = Some(spec.clone()); + } + } + ImportSpecifier::Named(_) => { + let spec_key = SpecifierKey::from_specifier(spec); + if let std::collections::hash_map::Entry::Vacant(e) = seen_named.entry(spec_key) + { + e.insert(()); + named_specs.push(spec.clone()); + } + } + } + } + } + + let mut result = Vec::new(); + + // Valid combinations in ES modules: + // - default only + // - namespace only + // - named only + // - default + named + // - default + namespace (ONLY these two, no named allowed) + // Note: namespace + named (without default) is NOT valid - must split + // Note: default + namespace + named is NOT valid - must split + + if let Some(namespace) = namespace_spec { + if default_spec.is_some() { + if named_specs.is_empty() { + // default + namespace only (valid combination) + result.push(ImportDecl { + span, + specifiers: vec![default_spec.unwrap(), namespace], + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + } else { + // default + namespace + named - MUST SPLIT + // Create one import for default + named + let mut specs = vec![default_spec.unwrap()]; + specs.extend(named_specs); + result.push(ImportDecl { + span, + specifiers: specs, + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + // Create one import for namespace + result.push(ImportDecl { + span, + specifiers: vec![namespace], + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + } + } else if named_specs.is_empty() { + // Just namespace + result.push(ImportDecl { + span, + specifiers: vec![namespace], + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + } else { + // namespace + named without default - MUST SPLIT + // Create one import for namespace + result.push(ImportDecl { + span, + specifiers: vec![namespace], + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + // Create one import for named + result.push(ImportDecl { + span, + specifiers: named_specs, + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + } + } else { + // No namespace - merge default and/or named + let mut specs = Vec::new(); + if let Some(default) = default_spec { + specs.push(default); + } + specs.extend(named_specs); + + result.push(ImportDecl { + span, + specifiers: specs, + src: Box::new(Str { + span: DUMMY_SP, + value: key.src.clone().into(), + raw: None, + }), + type_only: key.type_only, + with: first_decl.with.clone(), + phase: key.phase, + }); + } + + result } diff --git a/crates/swc_ecma_minifier/tests/fixture/issues/11133/input.js b/crates/swc_ecma_minifier/tests/fixture/issues/11133/input.js new file mode 100644 index 000000000000..bc44ad416aff --- /dev/null +++ b/crates/swc_ecma_minifier/tests/fixture/issues/11133/input.js @@ -0,0 +1,48 @@ +// Test case 1: Basic duplicate named imports +import { add } from 'math'; +import { subtract } from 'math'; +import { multiply } from 'math'; + +// Test case 2: Same export imported with different local names (should preserve both) +import { add as a } from 'calculator'; +import { add as b } from 'calculator'; + +// Test case 3: Mix of default and named imports +import defaultExport from 'module1'; +import { namedExport } from 'module1'; + +// Test case 4: Namespace import with named imports (CANNOT be merged - incompatible) +import * as utils from 'utils'; +import { helper } from 'utils'; + +// Test case 4b: Default with namespace (CAN be merged) +import defUtils from 'utils2'; +import * as utils2 from 'utils2'; + +// Test case 5: Side-effect import (should not be merged) +import 'polyfill'; +import 'polyfill'; + +// Test case 6: Different sources (should not be merged) +import { foo } from 'lib1'; +import { foo } from 'lib2'; + +// Test case 7: Duplicate named imports (exact same specifier) +import { duplicate } from 'dups'; +import { duplicate } from 'dups'; +import { duplicate } from 'dups'; + +// Test case 8: Mix of named imports with and without aliases +import { thing } from 'things'; +import { thing as renamedThing } from 'things'; +import { otherThing } from 'things'; + +// Use all imports to avoid dead code elimination +console.log(add, subtract, multiply); +console.log(a, b); +console.log(defaultExport, namedExport); +console.log(utils, helper); +console.log(defUtils, utils2); +console.log(foo); +console.log(duplicate); +console.log(thing, renamedThing, otherThing); diff --git a/crates/swc_ecma_minifier/tests/fixture/issues/11133/output.js b/crates/swc_ecma_minifier/tests/fixture/issues/11133/output.js new file mode 100644 index 000000000000..d64658503a70 --- /dev/null +++ b/crates/swc_ecma_minifier/tests/fixture/issues/11133/output.js @@ -0,0 +1,23 @@ +// Test case 1: Basic duplicate named imports +// Test case 5: Side-effect import (should not be merged) +import 'polyfill'; +import 'polyfill'; +// Test case 6: Different sources (should not be merged) +import { foo } from 'lib1'; +import { foo } from 'lib2'; +// Use all imports to avoid dead code elimination +console.log(add, subtract, multiply), console.log(a, b), console.log(defaultExport, namedExport), console.log(utils, helper), console.log(defUtils, utils2), console.log(foo), console.log(duplicate), console.log(thing, renamedThing, otherThing); +// Test case 4: Namespace import with named imports (CANNOT be merged - incompatible) +import * as utils from "utils"; +import { helper } from "utils"; +// Test case 8: Mix of named imports with and without aliases +import { thing, thing as renamedThing, otherThing } from "things"; +// Test case 2: Same export imported with different local names (should preserve both) +import { add as a, add as b } from "calculator"; +// Test case 4b: Default with namespace (CAN be merged) +import defUtils, * as utils2 from "utils2"; +// Test case 3: Mix of default and named imports +import defaultExport, { namedExport } from "module1"; +import { add, subtract, multiply } from "math"; +// Test case 7: Duplicate named imports (exact same specifier) +import { duplicate } from "dups";