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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/lazy-weeks-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
swc_ecma_minifier: patch
swc_core: patch
---

feat(es/minifier): Add merge_imports optimization pass to reduce bundle size
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
5 changes: 5 additions & 0 deletions crates/swc_ecma_minifier/src/option/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/swc_ecma_minifier/src/option/terser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
293 changes: 292 additions & 1 deletion crates/swc_ecma_minifier/src/pass/postcompress.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<u64>,
}

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<ImportKey, Vec<ImportDecl>> = 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<ImportDecl> {
let mut default_spec: Option<ImportSpecifier> = None;
let mut namespace_spec: Option<ImportSpecifier> = None;
let mut named_specs: Vec<ImportSpecifier> = Vec::new();
let mut seen_named: FxHashMap<SpecifierKey, ()> = 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
}
Loading
Loading