feat(minifier): merge import and export statements against the same module (#25533)

Compress
```js
import { b, g } from "./res-SWvxTGCX.js";
export { g as load_css, b as start };
```
to
```js
export { g as load_css, b as start } from "./res-SWvxTGCX.js";
```

Some combinations (e.g. namespace + named specifier) are not allowed in
`export`s while it's allowed in `import`s, so that part should be
reviewed with caution.

Note that `just minsize` result does not change because the input does
not contain any import / exports.

close https://github.com/oxc-project/oxc/issues/13047

I used AI to generate the code and then reviewed the code and fixed the
behavior & added a bunch of tests.

---------

Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
翠andCameron Clark authored and GitHub committed 2026-09-30 13:26:39 +00:00
1 parent ec4c2898dc
commit cc82598ffd
8 files changed
+360 -17

No files matched your search

+5
View File
@@ -2008,6 +2008,11 @@ impl<'a> ImportDeclarationSpecifier<'a> {
}
}
/// Returns the symbol ID of the bound local identifier.
pub fn symbol_id(&self) -> SymbolId {
self.local().symbol_id()
}
/// Returns the name of the bound local identifier for this import declaration specifier.
///
/// ## Example
@@ -1,15 +1,17 @@
use std::iter;
use crate::generated::ancestor::Ancestor;
use oxc_allocator::{ArenaBox, ArenaVec, TakeIn};
use oxc_allocator::{ArenaBox, ArenaHashMap, ArenaVec, CloneIn, GetAllocator, TakeIn};
use oxc_ast::ast::*;
use oxc_ast_visit::{VisitJs, walk_js};
use oxc_compat::ESFeature;
use oxc_ecmascript::{
constant_evaluation::{ConstantEvaluation, DetermineValueType, IsLiteralValue, ValueType},
side_effects::MayHaveSideEffects,
};
use oxc_semantic::ScopeFlags;
use oxc_span::{ContentEq, GetSpan, GetSpanMut, SPAN};
use oxc_syntax::symbol::SymbolId;
use crate::{TraverseCtx, is_terminated::IsTerminated, keep_var::KeepVar};
@@ -131,6 +133,239 @@ impl<'a> PeepholeOptimizations {
false
}
/// Check whether an import can be merged with a local named export.
fn can_merge_import_export(
import_decl: &ImportDeclaration<'a>,
export_decl: &ExportNamedDeclaration<'a>,
ctx: &TraverseCtx<'a>,
) -> bool {
let Some(import_specifiers) = import_decl.specifiers.as_ref() else { return false };
let is_namespace_import = matches!(
import_specifiers.as_slice(),
[ImportDeclarationSpecifier::ImportNamespaceSpecifier(_)]
);
if import_decl.phase.is_some()
|| import_decl.import_kind.is_type()
|| export_decl.export_kind.is_type()
|| import_specifiers.is_empty()
|| import_specifiers.len() > export_decl.specifiers.len()
|| (is_namespace_import && !ctx.supports_feature(ESFeature::ES2020ExportNamespaceFrom))
|| (is_namespace_import && export_decl.specifiers.len() != 1)
|| ctx.scoping().root_scope_flags().contains_direct_eval()
|| (!is_namespace_import
&& !import_specifiers.iter().all(|specifier| match specifier {
ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
!specifier.import_kind.is_type()
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => true,
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => false,
}))
{
return false;
}
// Every local export must refer to one of the imported bindings, and
// every imported binding must be used exactly by this export.
for import_specifier in import_specifiers {
let symbol_id = import_specifier.symbol_id();
let export_count = export_decl
.specifiers
.iter()
.filter(|export_specifier| {
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
return false;
};
ctx.scoping().get_reference(id.reference_id()).symbol_id() == Some(symbol_id)
})
.count();
if export_count == 0
|| ctx.scoping().get_resolved_reference_ids(symbol_id).len() != export_count
{
return false;
}
}
export_decl.specifiers.iter().all(|export_specifier| {
if export_specifier.export_kind.is_type() {
return false;
}
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
return false;
};
let Some(symbol_id) = ctx.scoping().get_reference(id.reference_id()).symbol_id() else {
return false;
};
import_specifiers
.iter()
.any(|import_specifier| import_specifier.symbol_id() == symbol_id)
})
}
/// Merge an import and a local named export into a direct
/// re-export.
///
/// ```js
/// import { foo as bar } from "module";
/// export { bar as baz };
/// // becomes
/// export { foo as baz } from "module";
///
/// import * as namespace from "module";
/// export { namespace };
/// // becomes
/// export * as namespace from "module";
///
/// import defaultExport from "module";
/// export { defaultExport };
/// // becomes
/// export { default as defaultExport } from "module";
/// ```
///
/// The import declaration can only be removed when all of its bindings are
/// represented by the export and have no other references. This also keeps
/// direct `eval` from observing a binding that is removed here.
pub fn merge_import_export(stmts: &mut ArenaVec<'a, Statement<'a>>, ctx: &mut TraverseCtx<'a>) {
let mut import_export_cache: ArenaHashMap<'a, SymbolId, usize> =
ArenaHashMap::new_in(ctx.allocator());
let mut merges = ArenaVec::new_in(ctx);
// Collect the candidates
for (statement_index, stmt) in stmts.iter().enumerate() {
match stmt {
Statement::ImportDeclaration(import_decl) => {
if let Some(specifiers) = import_decl.specifiers.as_ref() {
import_export_cache.extend(
specifiers
.iter()
.map(|specifier| (specifier.symbol_id(), statement_index)),
);
}
}
Statement::ExportNamedDeclaration(export_decl) => {
let Some(import_index) = export_decl.specifiers.iter().find_map(|spec| {
let ModuleExportName::IdentifierReference(id) = &spec.local else {
return None;
};
let Some(symbol_id) =
ctx.scoping().get_reference(id.reference_id()).symbol_id()
else {
return None;
};
import_export_cache.get(&symbol_id).copied()
}) else {
continue;
};
let Some(Statement::ImportDeclaration(import_decl)) = stmts.get(import_index)
else {
continue;
};
if !Self::can_merge_import_export(import_decl, export_decl, ctx) {
continue;
}
merges.push((import_index, statement_index));
if let Some(specifiers) = import_decl.specifiers.as_ref() {
for specifier in specifiers {
let symbol_id = specifier.symbol_id();
if import_export_cache.get(&symbol_id) == Some(&import_index) {
import_export_cache.remove(&symbol_id);
}
}
}
}
_ => {}
}
}
// Apply in reverse order so removing an export does not shift the
// indices of any merge that is still waiting to be applied.
for &(import_index, export_index) in merges.iter().rev() {
// The old export contains references to the local import binding.
// Mark those references before replacing them with the imported
// module name.
let old_export = stmts.remove(export_index);
ctx.drop_statement(&old_export);
let Statement::ExportNamedDeclaration(mut export_decl) = old_export else {
unreachable!();
};
let old_import = std::mem::replace(
stmts.get_mut(import_index).unwrap(),
Statement::new_empty_statement(SPAN, ctx),
);
ctx.drop_statement(&old_import);
let Statement::ImportDeclaration(import_decl) = old_import else {
unreachable!();
};
let ImportDeclaration {
specifiers: Some(mut import_specifiers),
source,
with_clause,
..
} = import_decl.unbox()
else {
unreachable!();
};
let is_namespace_import = matches!(
import_specifiers.as_slice(),
[ImportDeclarationSpecifier::ImportNamespaceSpecifier(_)]
);
if is_namespace_import {
let exported = export_decl.specifiers.pop().unwrap().exported;
ctx.replace_statement(
stmts.get_mut(import_index).unwrap(),
Statement::new_export_all_declaration(
export_decl.span,
Some(exported),
source,
with_clause,
export_decl.export_kind,
ctx,
),
);
continue;
}
for export_specifier in &mut export_decl.specifiers {
let ModuleExportName::IdentifierReference(id) = &export_specifier.local else {
unreachable!();
};
let symbol_id = ctx.scoping().get_reference(id.reference_id()).symbol_id().unwrap();
let import_specifier = import_specifiers
.iter_mut()
.find(|import_specifier| import_specifier.symbol_id() == symbol_id)
.unwrap();
export_specifier.local = match import_specifier {
ImportDeclarationSpecifier::ImportSpecifier(import_specifier) => {
import_specifier.imported.clone_in(ctx.allocator())
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(import_default) => {
ModuleExportName::IdentifierName(IdentifierName::new(
import_default.span,
"default",
ctx,
))
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => unreachable!(),
};
}
ctx.replace_statement(
stmts.get_mut(import_index).unwrap(),
Statement::new_export_from_declaration(
export_decl.span,
export_decl.specifiers.take_in(ctx),
source,
export_decl.export_kind,
with_clause,
ctx,
),
);
}
}
fn minimize_statement(
stmt: Statement<'a>,
stmts: &mut StatementIter<'a>,
+2 -1
View File
@@ -388,7 +388,8 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
ctx.state.body_frames.pop();
}
fn exit_program(&mut self, _program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
fn exit_program(&mut self, program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
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());
}
@@ -249,6 +249,18 @@ impl<'a> PeepholeOptimizations {
/// import 'b'
/// ```
pub fn remove_unused_import_specifiers(stmt: &mut Statement<'a>, ctx: &mut TraverseCtx<'a>) {
let Statement::ImportDeclaration(import_decl) = stmt else { return };
// `import {} from 'x'` -> `import 'x'`
if import_decl.phase.is_none()
&& import_decl.import_kind.is_value()
&& import_decl.specifiers.as_ref().is_some_and(|specifiers| specifiers.is_empty())
{
import_decl.specifiers = None;
ctx.notice_change();
return;
}
if ctx.options().treeshake.invalid_import_side_effects
|| ctx.options().unused == CompressOptionsUnused::Keep
{
@@ -261,8 +273,6 @@ impl<'a> PeepholeOptimizations {
debug_assert!(!ctx.source_type().is_script(), "imports are not allowed in script mode");
let Statement::ImportDeclaration(import_decl) = stmt else { return };
if let Some(phase) = import_decl.phase {
let (ImportPhase::Defer | ImportPhase::Source) = phase;
if ctx.scoping().symbol_is_unused(
@@ -0,0 +1,84 @@
use crate::{test, test_same, test_target};
#[test]
fn merge_import_and_export() {
test("import { foo } from 'somewhere'; export { foo };", "export { foo } from 'somewhere';");
test(
"import { foo as bar } from 'somewhere'; export { bar as baz };",
"export { foo as baz } from 'somewhere';",
);
test(
"import foo from 'somewhere'; export { foo };",
"export { default as foo } from 'somewhere';",
);
test(
"import foo from 'somewhere'; export { foo as bar };",
"export { default as bar } from 'somewhere';",
);
test(
"import { default as foo } from 'somewhere'; export { foo };",
"export { default as foo } from 'somewhere';",
);
test(
"import foo, { bar } from 'somewhere'; export { foo, bar };",
"export { default as foo, bar } from 'somewhere';",
);
test(
"import foo, { bar } from 'somewhere'; export { bar, foo };",
"export { bar, default as foo } from 'somewhere';",
);
test(
"import { foo, bar as baz } from 'somewhere'; export { baz, foo as qux };",
"export { bar as baz, foo as qux } from 'somewhere';",
);
test(
"import { foo } from 'somewhere'; export { foo as bar, foo as baz };",
"export { foo as bar, foo as baz } from 'somewhere';",
);
test("import {} from 'somewhere';", "import 'somewhere';");
test("import {} from 'somewhere'; export {};", "import 'somewhere'; export {};");
test(
"import { foo } from 'somewhere' with { type: 'json' }; export { foo };",
"export { foo } from 'somewhere' with { type: 'json' };",
);
test("import * as foo from 'somewhere'; export { foo };", "export * as foo from 'somewhere';");
test(
"import * as foo from 'somewhere'; export { foo as bar };",
"export * as bar from 'somewhere';",
);
test_same("import * as foo from 'somewhere'; export { foo, foo as bar };");
test(
"import * as foo from 'somewhere'; export { foo as \"bar\" };",
"export * as \"bar\" from 'somewhere';",
);
test_same("import foo, * as bar from 'foo'; export { foo, bar };");
test_target(
"import * as foo from 'somewhere'; export { foo };",
"import * as foo from 'somewhere'; export { foo };",
"es2019",
);
test_target(
"import * as foo from 'somewhere'; export { foo };",
"export * as foo from 'somewhere';",
"es2020",
);
// preserve import order
test(
"import { foo } from 'foo'; import { bar } from 'bar'; export { bar }; export { foo };",
"export { foo } from 'foo'; export { bar } from 'bar';",
);
test(
"import { foo } from 'foo'; import 'side-effect'; import { bar } from 'bar'; export { bar }; export { foo };",
"export { foo } from 'foo'; import 'side-effect'; export { bar } from 'bar';",
);
// do not merge if it's used in the module
test_same("import { foo } from 'somewhere'; export { foo }; console.log(foo);");
test(
"import { foo } from 'somewhere'; console.log(0); export { foo };",
"export { foo } from 'somewhere'; console.log(0);",
);
test_same("import foo, { bar } from 'somewhere'; export { bar };");
test_same("import * as foo from 'somewhere'; console.log(foo); export { foo };");
test_same("import { foo } from 'somewhere'; export { foo }; eval('foo');");
}
@@ -8,6 +8,7 @@ mod inline;
mod inline_single_use_variable;
mod manual_pure_functions;
mod merge_assignments_to_declarations;
mod merge_import_export;
mod minimize_binary_expression;
mod minimize_conditional_expression;
mod minimize_conditions;
@@ -1005,6 +1005,13 @@ fn keep_recursive_function_with_unused_keep_option() {
fn remove_unused_import_specifiers() {
let options = CompressOptions::smallest();
test_options("import {} from 'a';", "import 'a';", &options);
test_options(
"import {} from 'a' with { type: 'json' };",
"import 'a' with { type: 'json' };",
&options,
);
test_options("import a from 'a'", "import 'a';", &options);
test_options("import a from 'a'; foo()", "import 'a'; foo();", &options);
test_same_options(
@@ -1064,7 +1071,7 @@ fn remove_unused_import_specifiers() {
test_options("import { a as b } from 'a'", "import 'a';", &options);
test_same_options("import { a as b } from 'a'; foo(b);", &options);
test_same_options("import { a } from 'a'; export { a };", &options);
test_options("import { a } from 'a'; export { a };", "export { a } from 'a';", &options);
// Keep imports when direct eval is present
test_same_options("import { a } from 'a'; eval('a');", &options);
test_same_options("import a from 'a'; eval('a');", &options);
@@ -5,9 +5,9 @@ checker.ts:
sys deallocs: 122
sys alloc bytes: 14996287 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 56214
arena allocs: 56217
arena reallocs: 5084
arena size: 16317568 # 16.32 MB
arena size: 16369368 # 16.37 MB
App.tsx:
file size: 415342 # 415.34 kB
@@ -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: 7469
arena allocs: 7490
arena reallocs: 920
arena size: 2683792 # 2.68 MB
arena size: 2733056 # 2.73 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: 15
arena allocs: 21
arena reallocs: 0
arena size: 35104 # 35.10 kB
arena size: 36112 # 36.11 kB
pdf.mjs:
file size: 567296 # 567.30 kB
@@ -40,7 +40,7 @@ pdf.mjs:
sys peak growth: 3693355 # 3.69 MB
arena allocs: 19585
arena reallocs: 2574
arena size: 5710976 # 5.71 MB
arena size: 5710848 # 5.71 MB
antd.js:
file size: 6686316 # 6.69 MB
@@ -51,7 +51,7 @@ antd.js:
sys peak growth: 19934725 # 19.93 MB
arena allocs: 181528
arena reallocs: 34406
arena size: 43039192 # 43.04 MB
arena size: 43039048 # 43.04 MB
binder.ts:
file size: 193077 # 193.08 kB
@@ -60,9 +60,9 @@ binder.ts:
sys deallocs: 33
sys alloc bytes: 963008 # 963.01 kB
sys peak growth: 657404 # 657.40 kB
arena allocs: 2922
arena allocs: 2924
arena reallocs: 244
arena size: 1074912 # 1.07 MB
arena size: 1083768 # 1.08 MB
kitchen-sink.tsx:
file size: 732898 # 732.90 kB
@@ -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: 31033
arena allocs: 31043
arena reallocs: 3012
arena size: 8439864 # 8.44 MB
arena size: 8440280 # 8.44 MB