feat(minifier): merge import statements against the same module (#25534)

Merge compatible import declarations for the same module, including
declarations separated by imports from other modules:

```js
import { b } from "./res-SWvxTGCX.js";
import { g } from "./res-SWvxTGCX.js";
// becomes
import { b, g } from "./res-SWvxTGCX.js";
```

Preserve separate declarations when import attributes, phases,
type/value modes, or namespace specifiers prevent merging. Combine
multiple default bindings using named `default` imports. Keep
source-phase imports separate because their syntax permits only one
binding; a regression test checks that they remain parseable and
idempotent.

---------

Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
翠andCameron Clark authored and GitHub committed 2026-09-30 14:45:00 +01:00
1 parent cc82598ffd
commit adb1139e64
6 files changed
+208 -8

No files matched your search

@@ -133,6 +133,147 @@ impl<'a> PeepholeOptimizations {
false
}
/// Check whether two import declarations can be merged.
fn can_merge_imports(first: &ImportDeclaration<'a>, second: &ImportDeclaration<'a>) -> bool {
if first.source.value != second.source.value
|| first.phase != second.phase
|| first.phase == Some(ImportPhase::Source)
|| first.import_kind != second.import_kind
|| first.import_kind.is_type()
|| first.with_clause.content_ne(&second.with_clause)
{
return false;
}
// Additional default specifiers can be represented as named imports of
// `default`. Namespace and named specifiers cannot coexist.
let mut default_count = 0;
let mut has_namespace = false;
let mut has_named = false;
for specifier in first
.specifiers
.iter()
.flat_map(|specifiers| specifiers.iter())
.chain(second.specifiers.iter().flat_map(|specifiers| specifiers.iter()))
{
match specifier {
ImportDeclarationSpecifier::ImportSpecifier(_) => has_named = true,
ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => default_count += 1,
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => {
if has_namespace {
return false;
}
has_namespace = true;
}
}
}
!(has_namespace && (has_named || default_count > 1))
}
/// Convert a default import into a named import of the module's `default`
/// export. This allows multiple default bindings in one import clause.
fn default_to_named_import(
default_specifier: ImportDeclarationSpecifier<'a>,
ctx: &TraverseCtx<'a>,
) -> ImportDeclarationSpecifier<'a> {
let ImportDeclarationSpecifier::ImportDefaultSpecifier(default_specifier) =
default_specifier
else {
unreachable!();
};
let ImportDefaultSpecifier { span, local, .. } = default_specifier.unbox();
ImportDeclarationSpecifier::new_import_specifier(
span,
ModuleExportName::IdentifierName(IdentifierName::new(span, "default", ctx)),
local,
ImportOrExportKind::Value,
ctx,
)
}
/// Merge import declarations that load the same module request.
///
/// ```js
/// import { foo } from "module";
/// import { bar } from "other";
/// import { baz } from "module";
/// // becomes
/// import { foo, baz } from "module";
/// import { bar } from "other";
/// ```
pub fn merge_imports(stmts: &mut ArenaVec<'a, Statement<'a>>, ctx: &mut TraverseCtx<'a>) {
let mut import_cache: ArenaHashMap<'a, &'a str, ArenaVec<'a, usize>> =
ArenaHashMap::new_in(ctx.allocator());
let mut merges = ArenaVec::new_in(ctx);
// Merge into the target immediately so later declarations are checked
// against the combined import clause. Keep every unmerged declaration
// in a source group because import attributes, phases, and type/value
// mode may differ between declarations with the same source string.
for statement_index in 0..stmts.len() {
let Statement::ImportDeclaration(import_decl) = stmts.get(statement_index).unwrap()
else {
continue;
};
let candidates = import_cache
.entry(import_decl.source.value.as_str())
.or_insert_with(|| ArenaVec::new_in(ctx));
let target_index = candidates.iter().find_map(|&target_index| {
let Some(Statement::ImportDeclaration(target)) = stmts.get(target_index) else {
return None;
};
Self::can_merge_imports(target, import_decl).then_some(target_index)
});
let Some(target_index) = target_index else {
candidates.push(statement_index);
continue;
};
{
let source_stmt = stmts.get(statement_index).unwrap();
ctx.drop_statement(source_stmt);
}
let Statement::ImportDeclaration(source_import) =
stmts.get_mut(statement_index).unwrap()
else {
unreachable!();
};
let Some(mut source_specifiers) = source_import.specifiers.take() else {
merges.push((target_index, statement_index));
continue;
};
let Statement::ImportDeclaration(target_import) = stmts.get_mut(target_index).unwrap()
else {
unreachable!();
};
let target_specifiers =
target_import.specifiers.get_or_insert_with(|| ArenaVec::new_in(ctx));
let target_has_default = target_specifiers.iter().any(|specifier| {
matches!(specifier, ImportDeclarationSpecifier::ImportDefaultSpecifier(_))
});
if let Some(default_index) = source_specifiers.iter().position(|specifier| {
matches!(specifier, ImportDeclarationSpecifier::ImportDefaultSpecifier(_))
}) {
let default_specifier = source_specifiers.remove(default_index);
if target_has_default {
target_specifiers.push(Self::default_to_named_import(default_specifier, ctx));
} else {
target_specifiers.insert(0, default_specifier);
}
}
target_specifiers.append(&mut source_specifiers);
merges.push((target_index, statement_index));
}
// Remove imports in reverse order so removing a later import does not
// shift the indices of any merge that is still waiting to be applied.
for &(_, source_index) in merges.iter().rev() {
stmts.remove(source_index);
}
}
/// Check whether an import can be merged with a local named export.
fn can_merge_import_export(
import_decl: &ImportDeclaration<'a>,
+1
View File
@@ -389,6 +389,7 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
}
fn exit_program(&mut self, program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
Self::merge_imports(&mut program.body, ctx);
Self::merge_import_export(&mut program.body, ctx);
// Private member usage is collected only in full optimization mode.
debug_assert!(ctx.is_tree_shake_only() || ctx.state.private_member_usage.is_at_root());
@@ -2,6 +2,10 @@ use crate::{test, test_same, test_target};
#[test]
fn merge_import_and_export() {
test(
"import { foo }from 'foo'; import { bar } from 'bar'; import {foo2} from 'foo'; export {foo, foo2}",
"export { foo, foo2 } from 'foo'; import { bar } from 'bar';",
);
test("import { foo } from 'somewhere'; export { foo };", "export { foo } from 'somewhere';");
test(
"import { foo as bar } from 'somewhere'; export { bar as baz };",
@@ -0,0 +1,53 @@
use crate::{test, test_same};
#[test]
fn preserve_source_imports() {
test_same("import source foo from 'foo'; import source bar from 'foo'; console.log(foo, bar);");
}
#[test]
fn merge_imports() {
test(
"import { foo } from 'foo'; import { bar } from 'foo';",
"import { foo, bar } from 'foo';",
);
test(
"import { foo } from 'foo'; import { bar } from 'bar'; import { foo2 } from 'foo';",
"import { foo, foo2 } from 'foo'; import { bar } from 'bar';",
);
test(
"import { foo as foo1 } from 'foo'; import { bar as bar1 } from 'foo';",
"import { foo as foo1, bar as bar1 } from 'foo';",
);
test(
"import { foo } from 'foo'; import { bar } from 'bar'; import { foo2 } from 'foo'; import { bar2 } from 'bar';",
"import { foo, foo2 } from 'foo'; import { bar, bar2 } from 'bar';",
);
test(
"import { foo } from 'foo'; import { bar } from 'foo'; import { baz } from 'foo';",
"import { foo, bar, baz } from 'foo';",
);
test("import foo from 'foo'; import { bar } from 'foo';", "import foo, { bar } from 'foo';");
test("import { bar } from 'foo'; import foo from 'foo';", "import foo, { bar } from 'foo';");
test("import foo from 'foo'; import * as ns from 'foo';", "import foo, * as ns from 'foo';");
test("import * as ns from 'foo'; import foo from 'foo';", "import foo, * as ns from 'foo';");
test(
"import foo from 'foo'; import bar from 'foo';",
"import foo, { default as bar } from 'foo';",
);
test(
"import foo from 'foo'; import bar from 'foo'; import baz from 'foo';",
"import foo, { default as bar, default as baz } from 'foo';",
);
test_same("import { foo } from 'foo'; import * as ns from 'foo';");
test(
"import foo from 'foo'; import bar from 'foo'; import * as ns from 'foo';",
"import foo, { default as bar } from 'foo'; import * as ns from 'foo';",
);
test_same("import * as ns1 from 'foo'; import * as ns2 from 'foo';");
test_same("import { foo } from 'foo' with { type: 'json' }; import { bar } from 'foo';");
test_same(
"import { foo } from 'foo' with { type: 'json' }; import { bar } from 'foo' with { type: 'css' };",
);
}
@@ -9,6 +9,7 @@ mod inline_single_use_variable;
mod manual_pure_functions;
mod merge_assignments_to_declarations;
mod merge_import_export;
mod merge_imports;
mod minimize_binary_expression;
mod minimize_conditional_expression;
mod minimize_conditions;
@@ -5,7 +5,7 @@ checker.ts:
sys deallocs: 122
sys alloc bytes: 14996287 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 56217
arena allocs: 56229
arena reallocs: 5084
arena size: 16369368 # 16.37 MB
@@ -16,9 +16,9 @@ App.tsx:
sys deallocs: 61
sys alloc bytes: 2128539 # 2.13 MB
sys peak growth: 1612427 # 1.61 MB
arena allocs: 7490
arena allocs: 7727
arena reallocs: 920
arena size: 2733056 # 2.73 MB
arena size: 2765960 # 2.77 MB
RadixUIAdoptionSection.jsx:
file size: 2520 # 2.52 kB
@@ -27,9 +27,9 @@ RadixUIAdoptionSection.jsx:
sys deallocs: 21
sys alloc bytes: 17400 # 17.40 kB
sys peak growth: 11628 # 11.63 kB
arena allocs: 21
arena allocs: 35
arena reallocs: 0
arena size: 36112 # 36.11 kB
arena size: 37216 # 37.22 kB
pdf.mjs:
file size: 567296 # 567.30 kB
@@ -60,7 +60,7 @@ binder.ts:
sys deallocs: 33
sys alloc bytes: 963008 # 963.01 kB
sys peak growth: 657404 # 657.40 kB
arena allocs: 2924
arena allocs: 2930
arena reallocs: 244
arena size: 1083768 # 1.08 MB
@@ -71,6 +71,6 @@ kitchen-sink.tsx:
sys deallocs: 1854
sys alloc bytes: 5304252 # 5.30 MB
sys peak growth: 3707425 # 3.71 MB
arena allocs: 31043
arena allocs: 31108
arena reallocs: 3012
arena size: 8440280 # 8.44 MB
arena size: 8444928 # 8.44 MB