Skip to content

Commit a01dee1

Browse files
delino[bot]github-actions[bot]claudekdy1
authored
feat(es/minifier): Add merge_imports optimization pass to reduce bundle size (#11151)
## Summary Implements a new optimization pass in the SWC ECMAScript minifier to merge duplicate import statements from the same module source, reducing bundle size by eliminating redundant imports. Fixes #11133 ## Changes ### Core Implementation - **New file**: `crates/swc_ecma_minifier/src/pass/merge_imports.rs` - Groups imports by source module and metadata (type_only, phase, with clause) - Merges compatible import specifiers while respecting ES module syntax constraints - Handles all valid import combinations: - ✅ Multiple named imports → merged into single import - ✅ Default + named imports → merged - ✅ Default + namespace imports → merged - ❌ Namespace + named imports (without default) → kept separate (invalid ES syntax) - Deduplicates exact duplicate imports - Preserves different aliases for the same export ### Integration - **Modified**: `crates/swc_ecma_minifier/src/pass/mod.rs` - Added merge_imports module - **Modified**: `crates/swc_ecma_minifier/src/lib.rs` - Integrated pass into minifier pipeline (runs before merge_exports) - **Modified**: `crates/swc_ecma_minifier/src/option/mod.rs` - Added `merge_imports: bool` config option (default: true) - **Modified**: `crates/swc_ecma_minifier/src/option/terser.rs` - Added terser compatibility ### Testing - **New**: `crates/swc_ecma_minifier/tests/fixture/issues/11133/` - Comprehensive test cases covering all scenarios - Tests verified passing with `cargo test --test compress -- 11133` ## Example ### Before ```javascript import { add } from 'math'; import { subtract } from 'math'; import { multiply } from 'math'; ``` ### After ```javascript import { add, subtract, multiply } from 'math'; ``` ## Test Plan - [x] All existing minifier tests pass - [x] New test fixtures for issue #11133 pass - [x] Handles edge cases: - Side-effect imports (preserved) - Type-only imports (handled separately) - Import attributes/assertions (preserved) - Namespace + named incompatibility (kept separate) ## Configuration Users can control this optimization via the `compress.merge_imports` option in `.swcrc`: ```json { "jsc": { "minify": { "compress": { "merge_imports": true } } } } ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Donny/강동윤 <kdy.1997.dev@gmail.com>
1 parent b6f4d1f commit a01dee1

File tree

9 files changed

+381
-9
lines changed

9 files changed

+381
-9
lines changed

.changeset/lazy-weeks-allow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
swc_ecma_minifier: patch
3+
swc_core: patch
4+
---
5+
6+
feat(es/minifier): Add merge_imports optimization pass to reduce bundle size
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
//// [es6modulekindWithES5Target9.ts]
2-
import d from "mod";
3-
import { a } from "mod";
4-
import * as M from "mod";
52
export * from "mod";
63
export { b } from "mod";
74
export default d;
5+
import d, { a } from "mod";
6+
import * as M from "mod";
87
export { a, M, d };
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
//// [esnextmodulekindWithES5Target9.ts]
2-
import d from "mod";
3-
import { a } from "mod";
4-
import * as M from "mod";
52
export * from "mod";
63
export { b } from "mod";
74
export default d;
5+
import d, { a } from "mod";
6+
import * as M from "mod";
87
export { a, M, d };

crates/swc/tests/tsc-references/exportNamespace12.2.minified.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//// [main.ts]
2-
import { c } from './types';
3-
import * as types from './types';
42
console.log(c), console.log(types.c);
3+
import * as types from "./types";
4+
import { c } from "./types";
55
//// [types.ts]
66
export { };
77
//// [values.ts]

crates/swc_ecma_minifier/src/option/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ pub struct CompressOptions {
282282
#[cfg_attr(feature = "extra-serde", serde(alias = "loops"))]
283283
pub loops: bool,
284284

285+
#[cfg_attr(feature = "extra-serde", serde(default = "true_by_default"))]
286+
#[cfg_attr(feature = "extra-serde", serde(alias = "merge_imports"))]
287+
pub merge_imports: bool,
288+
285289
#[cfg_attr(feature = "extra-serde", serde(default))]
286290
pub module: bool,
287291

@@ -451,6 +455,7 @@ impl Default for CompressOptions {
451455
keep_fnames: false,
452456
keep_infinity: false,
453457
loops: true,
458+
merge_imports: true,
454459
module: false,
455460
negate_iife: true,
456461
passes: default_passes(),

crates/swc_ecma_minifier/src/option/terser.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ impl TerserCompressorOptions {
343343
keep_fnames: self.keep_fnames,
344344
keep_infinity: self.keep_infinity,
345345
loops: self.loops.unwrap_or(self.defaults),
346+
merge_imports: self.defaults,
346347
module: self.module,
347348
negate_iife: self.negate_iife.unwrap_or(self.defaults),
348349
passes: self.passes,

crates/swc_ecma_minifier/src/pass/postcompress.rs

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use swc_common::util::take::Take;
1+
use rustc_hash::FxHashMap;
2+
use swc_common::{util::take::Take, DUMMY_SP};
23
use swc_ecma_ast::*;
34

45
use crate::option::CompressOptions;
@@ -53,4 +54,294 @@ pub fn postcompress_optimizer(program: &mut Program, options: &CompressOptions)
5354
}
5455
}
5556
}
57+
58+
// Merge duplicate imports if enabled
59+
if options.merge_imports {
60+
merge_imports_in_module(module);
61+
}
62+
}
63+
64+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65+
struct ImportKey {
66+
src: String,
67+
type_only: bool,
68+
phase: ImportPhase,
69+
/// Hash of the `with` clause to group imports with the same assertions
70+
with_hash: Option<u64>,
71+
}
72+
73+
impl ImportKey {
74+
fn from_import_decl(decl: &ImportDecl) -> Self {
75+
use std::{
76+
collections::hash_map::DefaultHasher,
77+
hash::{Hash, Hasher},
78+
};
79+
80+
let with_hash = decl.with.as_ref().map(|w| {
81+
let mut hasher = DefaultHasher::new();
82+
// Hash the with clause structure
83+
format!("{w:?}").hash(&mut hasher);
84+
hasher.finish()
85+
});
86+
87+
Self {
88+
src: decl.src.value.to_string(),
89+
type_only: decl.type_only,
90+
phase: decl.phase,
91+
with_hash,
92+
}
93+
}
94+
}
95+
96+
/// Key to identify unique import specifiers.
97+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98+
enum SpecifierKey {
99+
/// Named import: (imported name, local name, is_type_only)
100+
Named(String, String, bool),
101+
/// Default import: (local name)
102+
Default(String),
103+
/// Namespace import: (local name)
104+
Namespace(String),
105+
}
106+
107+
impl SpecifierKey {
108+
fn from_specifier(spec: &ImportSpecifier) -> Self {
109+
match spec {
110+
ImportSpecifier::Named(named) => {
111+
let imported = named
112+
.imported
113+
.as_ref()
114+
.map(|n| match n {
115+
ModuleExportName::Ident(id) => id.sym.to_string(),
116+
ModuleExportName::Str(s) => s.value.to_string(),
117+
})
118+
.unwrap_or_else(|| named.local.sym.to_string());
119+
120+
SpecifierKey::Named(imported, named.local.sym.to_string(), named.is_type_only)
121+
}
122+
ImportSpecifier::Default(default) => {
123+
SpecifierKey::Default(default.local.sym.to_string())
124+
}
125+
ImportSpecifier::Namespace(ns) => SpecifierKey::Namespace(ns.local.sym.to_string()),
126+
}
127+
}
128+
}
129+
130+
/// Merge duplicate import statements from the same module source.
131+
///
132+
/// This optimization reduces bundle size by combining multiple imports from
133+
/// the same source into a single import declaration.
134+
fn merge_imports_in_module(module: &mut Module) {
135+
// Group imports by source and metadata
136+
let mut import_groups: FxHashMap<ImportKey, Vec<ImportDecl>> = FxHashMap::default();
137+
138+
for item in module.body.iter() {
139+
if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item {
140+
// Skip side-effect only imports (no specifiers)
141+
if import_decl.specifiers.is_empty() {
142+
continue;
143+
}
144+
145+
let key = ImportKey::from_import_decl(import_decl);
146+
import_groups
147+
.entry(key)
148+
.or_default()
149+
.push(import_decl.clone());
150+
}
151+
}
152+
153+
// Remove all imports that will be merged (except side-effect imports)
154+
module.body.retain(|item| {
155+
if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item {
156+
// Keep side-effect imports
157+
if import_decl.specifiers.is_empty() {
158+
return true;
159+
}
160+
161+
let key = ImportKey::from_import_decl(import_decl);
162+
// Only keep if there's just one import for this key (no merging needed)
163+
import_groups.get(&key).map_or(true, |v| v.len() <= 1)
164+
} else {
165+
true
166+
}
167+
});
168+
169+
// Create merged imports and add them back
170+
for (key, import_decls) in import_groups.iter() {
171+
if import_decls.len() <= 1 {
172+
// No merging needed, already retained above
173+
continue;
174+
}
175+
176+
let merged_imports = merge_import_decls(import_decls, key);
177+
for merged in merged_imports {
178+
module
179+
.body
180+
.push(ModuleItem::ModuleDecl(ModuleDecl::Import(merged)));
181+
}
182+
}
183+
}
184+
185+
/// Merge multiple ImportDecl nodes.
186+
/// Returns a Vec because in some cases (namespace + named), we need to create
187+
/// multiple import statements since they cannot be combined in valid ES syntax.
188+
fn merge_import_decls(decls: &[ImportDecl], key: &ImportKey) -> Vec<ImportDecl> {
189+
let mut default_spec: Option<ImportSpecifier> = None;
190+
let mut namespace_spec: Option<ImportSpecifier> = None;
191+
let mut named_specs: Vec<ImportSpecifier> = Vec::new();
192+
let mut seen_named: FxHashMap<SpecifierKey, ()> = FxHashMap::default();
193+
194+
let first_decl = &decls[0];
195+
let span = first_decl.span;
196+
197+
// Separate specifiers by type
198+
for decl in decls {
199+
for spec in &decl.specifiers {
200+
match spec {
201+
ImportSpecifier::Default(_) => {
202+
if default_spec.is_none() {
203+
default_spec = Some(spec.clone());
204+
}
205+
}
206+
ImportSpecifier::Namespace(_) => {
207+
if namespace_spec.is_none() {
208+
namespace_spec = Some(spec.clone());
209+
}
210+
}
211+
ImportSpecifier::Named(_) => {
212+
let spec_key = SpecifierKey::from_specifier(spec);
213+
if let std::collections::hash_map::Entry::Vacant(e) = seen_named.entry(spec_key)
214+
{
215+
e.insert(());
216+
named_specs.push(spec.clone());
217+
}
218+
}
219+
}
220+
}
221+
}
222+
223+
let mut result = Vec::new();
224+
225+
// Valid combinations in ES modules:
226+
// - default only
227+
// - namespace only
228+
// - named only
229+
// - default + named
230+
// - default + namespace (ONLY these two, no named allowed)
231+
// Note: namespace + named (without default) is NOT valid - must split
232+
// Note: default + namespace + named is NOT valid - must split
233+
234+
if let Some(namespace) = namespace_spec {
235+
if default_spec.is_some() {
236+
if named_specs.is_empty() {
237+
// default + namespace only (valid combination)
238+
result.push(ImportDecl {
239+
span,
240+
specifiers: vec![default_spec.unwrap(), namespace],
241+
src: Box::new(Str {
242+
span: DUMMY_SP,
243+
value: key.src.clone().into(),
244+
raw: None,
245+
}),
246+
type_only: key.type_only,
247+
with: first_decl.with.clone(),
248+
phase: key.phase,
249+
});
250+
} else {
251+
// default + namespace + named - MUST SPLIT
252+
// Create one import for default + named
253+
let mut specs = vec![default_spec.unwrap()];
254+
specs.extend(named_specs);
255+
result.push(ImportDecl {
256+
span,
257+
specifiers: specs,
258+
src: Box::new(Str {
259+
span: DUMMY_SP,
260+
value: key.src.clone().into(),
261+
raw: None,
262+
}),
263+
type_only: key.type_only,
264+
with: first_decl.with.clone(),
265+
phase: key.phase,
266+
});
267+
// Create one import for namespace
268+
result.push(ImportDecl {
269+
span,
270+
specifiers: vec![namespace],
271+
src: Box::new(Str {
272+
span: DUMMY_SP,
273+
value: key.src.clone().into(),
274+
raw: None,
275+
}),
276+
type_only: key.type_only,
277+
with: first_decl.with.clone(),
278+
phase: key.phase,
279+
});
280+
}
281+
} else if named_specs.is_empty() {
282+
// Just namespace
283+
result.push(ImportDecl {
284+
span,
285+
specifiers: vec![namespace],
286+
src: Box::new(Str {
287+
span: DUMMY_SP,
288+
value: key.src.clone().into(),
289+
raw: None,
290+
}),
291+
type_only: key.type_only,
292+
with: first_decl.with.clone(),
293+
phase: key.phase,
294+
});
295+
} else {
296+
// namespace + named without default - MUST SPLIT
297+
// Create one import for namespace
298+
result.push(ImportDecl {
299+
span,
300+
specifiers: vec![namespace],
301+
src: Box::new(Str {
302+
span: DUMMY_SP,
303+
value: key.src.clone().into(),
304+
raw: None,
305+
}),
306+
type_only: key.type_only,
307+
with: first_decl.with.clone(),
308+
phase: key.phase,
309+
});
310+
// Create one import for named
311+
result.push(ImportDecl {
312+
span,
313+
specifiers: named_specs,
314+
src: Box::new(Str {
315+
span: DUMMY_SP,
316+
value: key.src.clone().into(),
317+
raw: None,
318+
}),
319+
type_only: key.type_only,
320+
with: first_decl.with.clone(),
321+
phase: key.phase,
322+
});
323+
}
324+
} else {
325+
// No namespace - merge default and/or named
326+
let mut specs = Vec::new();
327+
if let Some(default) = default_spec {
328+
specs.push(default);
329+
}
330+
specs.extend(named_specs);
331+
332+
result.push(ImportDecl {
333+
span,
334+
specifiers: specs,
335+
src: Box::new(Str {
336+
span: DUMMY_SP,
337+
value: key.src.clone().into(),
338+
raw: None,
339+
}),
340+
type_only: key.type_only,
341+
with: first_decl.with.clone(),
342+
phase: key.phase,
343+
});
344+
}
345+
346+
result
56347
}

0 commit comments

Comments
 (0)