feat(minifier): remove unreachable recursive functions (#24125)

Reference counts cannot remove unreachable recursive functions because their self- and mutual references keep the counts nonzero even when no executable code can reach them.

This change computes function reachability after scoping is flushed. Normalize registers function declarations and bindings with implicit runtime observers. References outside registered functions make their targets live; references owned by a live registered function propagate liveness to their targets.

Graph deadness is used only to remove function declarations. Other transformations continue to use ordinary reference counts together with implicit-observability checks. Direct `eval` disables the analysis for the whole program while present. Exports, Script-root bindings, Annex B aliases, and `using` disposal remain observable even when they have no resolved AST reference.

## Example

Input:

```js
function self() { self() }
function a() { b() }
function b() { a() }
export {};
```

Output:

```js
export {};
```

## Supported

- Self-recursive and mutually recursive function declarations.
- References nested in blocks, arrows, function expressions, classes, and parameter defaults. The nearest registered function declaration owns each reference.
- ES modules, CommonJS, and local functions in Scripts. Script-root bindings remain visible to later scripts.
- Full compression and DCE-only mode.
- Reads, writes, exports, `using` disposal, and direct `eval` preserve live functions.
- Exact self-recursive variable initializers whose value is a `FunctionExpression` or `ArrowFunctionExpression`, including classic `for` initializers.

## Conservatively retained

- Mutual cycles between variable declarators, such as `const a = () => b(); const b = () => a()`.
- Recursive class cycles and mixed function/declarator/class cycles.
- Wrapped or arbitrary function-valued initializers, such as `const f = (effect(), () => f())`.
- Recursive declarators in `for-in` and `for-of` heads.
- Sloppy unhoisted Annex B block functions, whose runtime alias write is not represented by their block-scoped symbol.

## Performance

[CodSpeed's report for `e71c392`](https://github.com/oxc-project/oxc/pull/24125#issuecomment-4877260138) measured a 3.74% regression on `binder.ts`, where the analysis finds no dead graph. On `kitchen-sink.tsx`, removing 39 function declarations covering 25.3 kB of source early improved the minifier benchmark by 21.01% and the full pipeline by 8.76%. A separate 15-run Script/CommonJS corpus measured a 1.67% median increase overall, below the 2% acceptance threshold.

Verified with all-feature workspace tests, lint, rustdoc, formatting, AST generation, minsize and allocation snapshots, and the repeat-compression corpus.

Closes #13105
Closes #17364

Implemented with AI assistance (OpenAI Codex). The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository's AI usage policy.
This commit is contained in:
Dunqing committed 2026-07-18 03:23:27 +00:00
1 parent d76d89996f
commit 7d3c178756
20 files changed
+1639 -143

No files matched your search

+23 -7
View File
@@ -117,21 +117,37 @@ impl<'a> Compressor<'a> {
let initial_references_len = ctx.get_mut().scoping().references_len();
// Consume the drops Normalize recorded (`void x` -> `void 0`,
// drop_console), so pass 1 already observes the pruned reference
// counts and Normalize's drops cost no extra peephole pass.
PeepholeOptimizations::flush_pass_dirty(program, ctx.get_mut());
// counts and Normalize's drops cost no extra peephole pass. Normalize
// also registered function declarations and exports, so post-flush
// analysis makes source-level dead cycles (`function f() { f() }`)
// visible to pass 1. Ignore the result because pass 1 runs
// unconditionally and consumes any newly dead functions.
PeepholeOptimizations::end_pass(
program,
ctx.get_mut(),
/* force_liveness_analysis */ true,
);
// Start the loop from a clean signal: Normalize's drops are flushed
// above, so a Normalize-only mutation must not force a pointless
// extra iteration.
ctx.state_mut().take_mutated();
loop {
PeepholeOptimizations.run_once(program, ctx);
// A pass with no recorded mutation cannot have recorded drops
// (every drop helper also records a mutation), so the terminal
// iteration skips the flush.
if !ctx.state_mut().take_mutated() {
let mutated = ctx.state_mut().take_mutated();
// Flush every pass. Reachability is recomputed only when a removed
// reference belonged to a graph candidate or direct eval was
// dropped; only those changes can invalidate the previous verdict.
let found_new_dead_functions = PeepholeOptimizations::end_pass(
program,
ctx.get_mut(),
/* force_liveness_analysis */ false,
);
// Liveness requests another pass only for a newly dead function
// (bounded by the candidate set); ordinary AST mutation also keeps
// the fixed-point loop running. The iteration cap backstops churn.
if !mutated && !found_new_dead_functions {
break;
}
PeepholeOptimizations::flush_pass_dirty(program, ctx.get_mut());
if let Some(max) = max_iterations {
if iteration >= max {
break;
+1
View File
@@ -52,6 +52,7 @@ mod options;
mod peephole;
mod state;
mod symbol_facts;
mod symbol_liveness;
mod symbol_value;
mod traverse_context;
+1 -1
View File
@@ -77,7 +77,7 @@ impl<'a> PeepholeOptimizations {
symbol_id: SymbolId,
ctx: &TraverseCtx<'a>,
) -> bool {
if decl.init.is_none() || Self::keep_top_level_var_in_script_mode(ctx) {
if decl.init.is_none() || Self::is_script_root_scope(ctx) {
return false;
}
// `body_unsafe` is set by a preceding non-declarative statement, and the
@@ -1343,9 +1343,7 @@ impl<'a> PeepholeOptimizations {
ctx: &mut TraverseCtx<'a>,
non_scoped_literal_only: bool,
) -> bool {
if Self::keep_top_level_var_in_script_mode(ctx)
|| ctx.current_scope_flags().contains_direct_eval()
{
if Self::is_script_root_scope(ctx) || ctx.current_scope_flags().contains_direct_eval() {
return false;
}
@@ -1387,7 +1385,7 @@ impl<'a> PeepholeOptimizations {
declarations: &mut ArenaVec<'a, VariableDeclarator<'a>>,
ctx: &mut TraverseCtx<'a>,
) -> bool {
if Self::keep_top_level_var_in_script_mode(ctx)
if Self::is_script_root_scope(ctx)
|| ctx.current_scope_flags().contains_direct_eval()
|| kind.is_using()
{
@@ -1457,9 +1455,12 @@ impl<'a> PeepholeOptimizations {
else {
return true;
};
// we should check whether it's exported by `symbol_value.exported`
// because the variable might be exported with `export { foo }` rather than `export var foo`
if symbol_value.exported
// Implicitly observable bindings remain live independently of
// their resolved-reference count.
// An `export { foo }` specifier also contributes a reference, but
// consult the shared metadata explicitly for consistency with the
// other count-based consumers.
if ctx.state.symbol_is_implicitly_observable(prev_decl_id.symbol_id())
|| symbol_value.read_references_count > 1
|| symbol_value.write_references_count > 0
{
+24 -8
View File
@@ -32,7 +32,7 @@ use oxc_ast::ast::*;
use crate::{
ReusableTraverseCtx, Traverse, TraverseCtx, minifier_traverse::traverse_mut_with_ctx,
traverse_context::as_direct_eval_call,
symbol_liveness, traverse_context::as_direct_eval_call,
};
pub use self::normalize::{Normalize, NormalizeOptions};
@@ -396,12 +396,14 @@ impl<'a> PeepholeOptimizations {
/// references from scoping, refresh direct-eval flags if an `eval(...)`
/// call was dropped, and re-initialize the accumulator.
///
/// The `Compressor` driver calls this after `Normalize` (so the
/// fixed-point loop starts against already-pruned scoping and
/// Normalize's drops cost no extra peephole pass) and after every
/// peephole pass.
pub(crate) fn flush_pass_dirty(program: &Program<'a>, ctx: &mut TraverseCtx<'a>) {
/// [`Self::end_pass`] calls this after `Normalize` (so the fixed-point
/// loop starts against already-pruned scoping and Normalize's drops cost
/// no extra peephole pass) and after every peephole pass — quiet ones
/// included, where every step below is a cheap no-op.
fn flush_pass_dirty(program: &Program<'a>, ctx: &mut TraverseCtx<'a>) -> bool {
let had_dead = !ctx.state.dirty.dead_refs.is_empty();
let liveness_inputs_changed = ctx.state.dirty.eval_dropped
|| (had_dead && symbol_liveness::dead_references_affect_analysis(ctx));
// (1) Resolved references — direct consumption, no walk.
// Dirty data is built by `replace_*` / `drop_*` helpers as
@@ -445,6 +447,20 @@ impl<'a> PeepholeOptimizations {
ctx.state.dirty.dead_refs = BitSet::new_in(refs_len, ctx.allocator());
}
ctx.state.dirty.eval_dropped = false;
liveness_inputs_changed
}
/// End-of-pass sequence: flush the dirty accumulator into scoping, then
/// derive function reachability from those settled semantic references.
/// Keeping the pair together makes the ordering structural. Returns
/// whether newly dead functions demand another pass.
pub(crate) fn end_pass(
program: &Program<'a>,
ctx: &mut TraverseCtx<'a>,
force_liveness_analysis: bool,
) -> bool {
let liveness_inputs_changed = Self::flush_pass_dirty(program, ctx);
symbol_liveness::analyze(program, ctx, force_liveness_analysis || liveness_inputs_changed)
}
}
@@ -466,8 +482,8 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
// than reallocating (matching the `reset`/`clear` above).
*ctx.state.body_unsafe_stack.last_mut() =
(ctx.scoping().root_scope_id(), module_has_loaders);
// `PassDirty` is managed by the `Compressor` driver via
// `flush_pass_dirty`, not reset per traversal.
// `PassDirty` is managed by the end-of-pass sequence, not reset per
// traversal.
}
fn enter_function_body(&mut self, _body: &mut FunctionBody<'a>, ctx: &mut TraverseCtx<'a>) {
+35 -4
View File
@@ -11,7 +11,7 @@ use oxc_syntax::scope::ScopeFlags;
use super::PeepholeOptimizations;
use crate::{
ReusableTraverseCtx, Traverse, TraverseCtx, minifier_traverse::traverse_mut_with_ctx,
symbol_facts::SymbolFact,
symbol_facts::SymbolFact, symbol_liveness,
};
#[derive(Default)]
@@ -58,6 +58,38 @@ impl<'a> Traverse<'a> for Normalize {
}
}
// Normalize is the only traversal that builds stable liveness metadata:
// registered function declarations and implicitly observable bindings.
// Each post-flush analysis derives ownership and reachability from the
// current semantic references.
fn enter_function(&mut self, node: &mut Function<'a>, ctx: &mut TraverseCtx<'a>) {
symbol_liveness::register_function(node, ctx);
}
fn enter_variable_declaration(
&mut self,
node: &mut VariableDeclaration<'a>,
ctx: &mut TraverseCtx<'a>,
) {
symbol_liveness::register_using_declaration(node, ctx);
}
fn enter_export_named_declaration(
&mut self,
node: &mut ExportNamedDeclaration<'a>,
ctx: &mut TraverseCtx<'a>,
) {
symbol_liveness::register_named_export(node, ctx);
}
fn enter_export_default_declaration(
&mut self,
node: &mut ExportDefaultDeclaration<'a>,
ctx: &mut TraverseCtx<'a>,
) {
symbol_liveness::register_default_export(node, ctx);
}
fn exit_statements(
&mut self,
stmts: &mut ArenaVec<'a, Statement<'a>>,
@@ -309,9 +341,8 @@ impl<'a> Normalize {
return;
}
// `replace_expression` walks the dropped ident into `PassDirty`, so
// its resolved reference is pruned by the driver's pre-loop
// `flush_pass_dirty`, before pass 1 — otherwise the symbol would
// look referenced forever.
// its resolved reference is pruned by the pre-loop `end_pass` flush,
// before pass 1 — otherwise the symbol would look referenced forever.
let new_arg =
Expression::new_numeric_literal(ident.span, 0.0, None, NumberBase::Decimal, ctx);
ctx.replace_expression(&mut e.argument, new_arg);
@@ -2,14 +2,67 @@ use super::PeepholeOptimizations;
use crate::{CompressOptionsUnused, TraverseCtx};
use oxc_ast::ast::*;
use oxc_ecmascript::constant_evaluation::{DetermineValueType, ValueType};
use oxc_syntax::symbol::SymbolId;
impl<'a> PeepholeOptimizations {
pub(super) fn can_remove_unused_declarators(ctx: &TraverseCtx<'a>) -> bool {
ctx.state.options.unused != CompressOptionsUnused::Keep
&& !Self::keep_top_level_var_in_script_mode(ctx)
&& !Self::is_script_root_scope(ctx)
&& !ctx.scoping().root_scope_flags().contains_direct_eval()
}
/// Count-based unusedness for declaration removal and IIFE folding. The
/// assignment, member-write, and single-use-substitution consumers instead
/// pair `symbol_is_implicitly_observable` with their own count thresholds,
/// because some runtime semantics can observe a binding independently of
/// resolved references.
pub(super) fn symbol_is_unused_by_count(symbol_id: SymbolId, ctx: &TraverseCtx<'a>) -> bool {
!ctx.state.symbol_is_implicitly_observable(symbol_id)
&& ctx.scoping().symbol_is_unused(symbol_id)
}
/// Function declarations additionally consume graph deadness, allowing
/// self- and mutually-recursive cycles to be removed.
fn function_has_no_live_references(symbol_id: SymbolId, ctx: &TraverseCtx<'a>) -> bool {
Self::symbol_is_unused_by_count(symbol_id, ctx) || ctx.state.function_is_dead(symbol_id)
}
/// Return `true` when an exact function-valued initializer contains every
/// reference to its own binding. Creating a function or arrow has no side
/// effects, and without a reference from outside that function it can
/// never be called.
///
/// This check deliberately runs at the declarator removal site instead of
/// registering declarators in the recursive-function graph. That keeps
/// mutual declarator cycles unsupported, but also means statement
/// relocation cannot leave a dead candidate in a non-removable AST slot.
/// For example, `const f = () => f()` is removable, while adding `use(f)`
/// supplies an outside reference and keeps it.
fn self_recursive_function_declarator_is_unused(
decl: &VariableDeclarator<'a>,
symbol_id: SymbolId,
ctx: &TraverseCtx<'a>,
) -> bool {
let Some(function_scope_id) = decl.init.as_ref().and_then(|init| match init {
Expression::FunctionExpression(function) => function.scope_id.get(),
Expression::ArrowFunctionExpression(arrow) => arrow.scope_id.get(),
_ => None,
}) else {
return false;
};
// Covers exports, Script-root bindings, Annex B aliases, and `using`.
if ctx.state.symbol_is_implicitly_observable(symbol_id) {
return false;
}
ctx.scoping().get_resolved_references(symbol_id).all(|reference| {
ctx.scoping()
.scope_ancestors(reference.scope_id())
.any(|scope_id| scope_id == function_scope_id)
})
}
fn is_sync_iterator_expr(expr: &Expression<'a>, ctx: &TraverseCtx<'a>) -> bool {
match expr {
Expression::ArrayExpression(_)
@@ -44,7 +97,10 @@ impl<'a> PeepholeOptimizations {
match &decl.id {
BindingPattern::BindingIdentifier(ident) => {
if let Some(symbol_id) = ident.symbol_id.get() {
return ctx.scoping().symbol_is_unused(symbol_id);
return Self::symbol_is_unused_by_count(symbol_id, ctx)
|| Self::self_recursive_function_declarator_is_unused(
decl, symbol_id, ctx,
);
}
false
}
@@ -107,12 +163,10 @@ impl<'a> PeepholeOptimizations {
}
let Some(id) = &f.id else { return };
let Some(symbol_id) = id.symbol_id.get() else { return };
if Self::keep_top_level_var_in_script_mode(ctx)
|| ctx.current_scope_flags().contains_direct_eval()
{
if Self::is_script_root_scope(ctx) || ctx.current_scope_flags().contains_direct_eval() {
return;
}
if !ctx.scoping().symbol_is_unused(symbol_id) {
if !Self::function_has_no_live_references(symbol_id, ctx) {
return;
}
let new_stmt = Statement::new_empty_statement(f.span, ctx);
@@ -126,12 +180,10 @@ impl<'a> PeepholeOptimizations {
}
let Some(id) = &c.id else { return };
let Some(symbol_id) = id.symbol_id.get() else { return };
if Self::keep_top_level_var_in_script_mode(ctx)
|| ctx.current_scope_flags().contains_direct_eval()
{
if Self::is_script_root_scope(ctx) || ctx.current_scope_flags().contains_direct_eval() {
return;
}
if !ctx.scoping().symbol_is_unused(symbol_id) {
if !Self::symbol_is_unused_by_count(symbol_id, ctx) {
return;
}
if let Some(changed) = Self::remove_unused_class(c, ctx).map(|exprs| {
@@ -146,8 +198,8 @@ impl<'a> PeepholeOptimizations {
}
}
/// Do remove top level vars in script mode.
pub fn keep_top_level_var_in_script_mode(ctx: &TraverseCtx<'a>) -> bool {
/// Whether bindings in the current scope are visible to later scripts.
pub fn is_script_root_scope(ctx: &TraverseCtx<'a>) -> bool {
ctx.scoping.current_scope_id() == ctx.scoping().root_scope_id()
&& ctx.source_type().is_script()
}
@@ -761,9 +761,7 @@ impl<'a> PeepholeOptimizations {
else {
unreachable!()
};
if Self::keep_top_level_var_in_script_mode(ctx)
|| ctx.current_scope_flags().contains_direct_eval()
{
if Self::is_script_root_scope(ctx) || ctx.current_scope_flags().contains_direct_eval() {
return false;
}
let reference_id = ident.reference_id();
@@ -774,13 +772,14 @@ impl<'a> PeepholeOptimizations {
if ctx.scoping().symbol_flags(symbol_id).is_const_variable() {
return false;
}
// Cannot remove writes to implicitly observable bindings, for example
// `export let foo; foo = 1;`.
if ctx.state.symbol_is_implicitly_observable(symbol_id) {
return false;
}
let Some(symbol_value) = ctx.state.symbol_values.get_symbol_value(symbol_id) else {
return false;
};
// Cannot remove assignment to live bindings: `export let foo; foo = 1;`.
if symbol_value.exported {
return false;
}
if symbol_value.read_references_count > 0 {
return false;
}
@@ -802,7 +801,7 @@ impl<'a> PeepholeOptimizations {
/// `__proto__` setters) — the `MEMBER_WRITE_HAZARD` fact in
/// `MinifierState::symbol_facts`. See `docs/ASSUMPTIONS.md`.
fn remove_unused_member_assignment(e: &mut Expression<'a>, ctx: &mut TraverseCtx<'a>) -> bool {
if Self::keep_top_level_var_in_script_mode(ctx) {
if Self::is_script_root_scope(ctx) {
return false;
}
let Expression::AssignmentExpression(assign_expr) = &*e else { unreachable!() };
@@ -968,7 +967,7 @@ impl<'a> PeepholeOptimizations {
/// Four conditions must hold:
/// 1. The target is a single-level member expression (`A.foo`, not `a.b.c`)
/// 2. ALL references to the symbol are member write targets
/// 3. The symbol creates a fresh value (not an alias) and is not exported
/// 3. The symbol creates a fresh value and is not otherwise observable
/// 4. No `__proto__` write may have installed a setter that another
/// reference could trigger
fn is_member_assign_to_unused_binding(symbol_id: SymbolId, ctx: &TraverseCtx<'a>) -> bool {
@@ -984,11 +983,14 @@ impl<'a> PeepholeOptimizations {
return false;
}
// Check: symbol creates a fresh value (not an alias) and is not exported.
// Check: symbol creates a fresh value and is not implicitly observable.
if ctx.state.symbol_is_implicitly_observable(symbol_id) {
return false;
}
let Some(sv) = ctx.state.symbol_values.get_symbol_value(symbol_id) else {
return false;
};
if sv.kind == FreshValueKind::None || sv.exported {
if sv.kind == FreshValueKind::None {
return false;
}
// Check: all references are member write targets (O(1) via pre-computed count).
@@ -574,8 +574,9 @@ impl<'a> PeepholeOptimizations {
ctx.scoping().get_reference(is_null_id_ref.reference_id()).symbol_id();
// Plain `clone_in` resets every `reference_id` to `None`, making id
// aliasing structurally impossible; the loop below installs the one
// fresh reference the clone needs.
// aliasing structurally impossible. The fresh references below are
// stamped with the current scope, so the next post-flush graph
// analysis observes them directly from scoping.
let mut new_left_expr = typeof_binary_expr.clone_in(ctx.allocator());
if let Expression::BinaryExpression(new_left_expr_binary) = &mut new_left_expr {
new_left_expr_binary.operator =
@@ -1888,8 +1889,8 @@ impl<'a> PeepholeOptimizations {
}
/// Whether the expression's result will be discarded — bare expression
/// statement, or the init of a `var`/`let`/`const` whose binding has no
/// references and isn't exported. Used by the IIFE inliner to short-circuit
/// statement, or the init of a `var`/`let`/`const` whose binding is unused
/// by count. Used by the IIFE inliner to short-circuit
/// pure-annotated IIFEs to `void 0` so they drop regardless of body shape,
/// and to allow `(async () => {})()` / `(function* () {})()` (whose return
/// value isn't a meaningful result) to collapse in those positions too.
@@ -1908,32 +1909,12 @@ impl<'a> PeepholeOptimizations {
let BindingPattern::BindingIdentifier(ident) = decl.id() else {
return false;
};
if !ctx.scoping().symbol_is_unused(ident.symbol_id()) {
return false;
}
!Self::var_declaration_is_exported(ctx)
Self::symbol_is_unused_by_count(ident.symbol_id(), ctx)
}
_ => false,
}
}
/// `true` if the `VariableDeclaration` that contains the current expression
/// (entered via `VariableDeclaratorInit`) sits directly under an `export`
/// wrapper. Exports are cross-module reachable, and the inner
/// `VariableDeclaration` never routes through `handle_variable_declaration`
/// — dropping its init would silently break the export's runtime value.
///
/// Checks the exact ancestor slot above `VariableDeclaration` only;
/// walking the full chain would over-broaden the guard to function-local
/// vars inside exported functions.
fn var_declaration_is_exported(ctx: &TraverseCtx<'a>) -> bool {
// Only `ExportNamedDeclaration`'s `declaration` field can hold a
// `VariableDeclaration`. `export default` wraps a function / class /
// expression — never a `VariableDeclaration` — so no arm is needed
// for it.
matches!(ctx.ancestors().nth(2), Some(Ancestor::ExportNamedDeclarationDeclaration(_)))
}
/// Optimizes the usage of Immediately Invoked Function Expressions (IIFEs)
/// within the given expression and context by performing various substitutions
/// to clean up and simplify the code.
+34 -6
View File
@@ -8,13 +8,16 @@ use oxc_span::SourceType;
use oxc_str::Str;
use oxc_syntax::{scope::ScopeId, symbol::SymbolId};
use crate::{CompressOptions, symbol_facts::PersistentSymbolFacts, symbol_value::SymbolValues};
use crate::{
CompressOptions, symbol_facts::PersistentSymbolFacts, symbol_liveness::SymbolLiveness,
symbol_value::SymbolValues,
};
/// Dirty data accumulated by the `replace_*` / `drop_*` helper calls between
/// two consumption points. Live from `MinifierState::new` so the pre-loop
/// `Normalize` pass records drops through the same typed helpers as the
/// peephole loop; consumed and re-initialized by `flush_pass_dirty` in the
/// `Compressor` driver after `Normalize` and after every peephole pass.
/// peephole loop; consumed and reset or resized by the end-of-pass sequence
/// after `Normalize` and after every peephole pass.
pub struct PassDirty<'a> {
/// `ReferenceId`s whose AST node has been removed and not re-installed
/// in any later mutation this pass.
@@ -98,11 +101,18 @@ pub struct MinifierState<'a> {
mutated: bool,
/// Per-pass dirty accumulator populated by `replace_*` / `drop_*` helpers
/// as subtrees are removed. Consumed by `flush_pass_dirty` in the
/// `Compressor` driver (pre-loop and after each mutated pass) to drive
/// the incremental scoping refresh.
/// as subtrees are removed. Consumed by the end-of-pass sequence after
/// Normalize and every peephole pass to drive the incremental scoping
/// refresh.
pub(crate) dirty: PassDirty<'a>,
/// Implicitly observable bindings plus the optional recursive-function
/// graph. Present for modules or when unused removal is enabled; a `using`
/// declaration can also create it lazily. Stable metadata is seeded from
/// scoping and Normalize; post-flush analysis derives reachability from the
/// current semantic reference lists.
pub(crate) symbol_liveness: Option<SymbolLiveness<'a>>,
/// Scratch buffer reused by `try_fold_concat` to build template literal
/// quasis without allocating a fresh `String` per call.
pub concat_scratch: String,
@@ -116,6 +126,8 @@ impl<'a> MinifierState<'a> {
scoping: &Scoping,
allocator: &'a Allocator,
) -> Self {
let symbol_liveness =
SymbolLiveness::new_if_enabled(source_type, &options, scoping, allocator);
Self {
source_type,
options,
@@ -127,6 +139,7 @@ impl<'a> MinifierState<'a> {
body_unsafe_stack: NonEmptyStack::new((scoping.root_scope_id(), false)),
mutated: false,
dirty: PassDirty::new(scoping.references_len(), allocator),
symbol_liveness,
concat_scratch: String::new(),
}
}
@@ -143,6 +156,21 @@ impl<'a> MinifierState<'a> {
!self.dce || !self.options.treeshake.property_write_side_effects
}
/// Whether runtime semantics have an implicit observation channel that
/// remains even if every resolved reference disappears from the current
/// AST.
pub(crate) fn symbol_is_implicitly_observable(&self, symbol_id: SymbolId) -> bool {
self.symbol_liveness
.as_ref()
.is_some_and(|liveness| liveness.is_implicitly_observable(symbol_id))
}
/// Whether post-flush graph analysis proved a function declaration
/// unreachable from executing code.
pub(crate) fn function_is_dead(&self, symbol_id: SymbolId) -> bool {
self.symbol_liveness.as_ref().is_some_and(|liveness| liveness.function_is_dead(symbol_id))
}
/// Returns whether the AST was mutated since the last call, and resets.
/// Read and reset are one operation so the signal cannot be cleared
/// anywhere except at its single consumption point.
@@ -0,0 +1,631 @@
//! Symbol liveness: observability that reference counts cannot express, and
//! reachability for recursively referenced function declarations.
//!
//! Reference counting removes an acyclic unused declaration once its last
//! reference disappears, but it cannot remove `function a() { b() }
//! function b() { a() }`: each function keeps the other's count non-zero.
//! This module adds the missing reachability question: can executing code
//! reach a candidate function declaration?
//!
//! Four concepts define the analysis:
//!
//! 1. **Candidates** are eligible function declarations whose deadness may
//! require graph reachability. Normalize initially registers every eligible
//! declaration. Analysis permanently untracks a non-dead candidate once no
//! current reference has a registered function owner; it no longer needs
//! graph reachability, so ordinary removal checks handle it from then on.
//! Published-dead candidates remain tracked to enforce monotonicity. Other
//! declaration kinds continue to use reference counts.
//! Self-recursive function and arrow expressions in declarator initializers
//! are handled by a local check at their removal site
//! (`self_recursive_function_declarator_is_unused`) and need no graph state.
//! 2. **Ownership** comes from semantic scopes. The nearest registered function
//! declaration is a reference's owner. A reference from a candidate that is
//! not implicitly observable is stored as `(owner, target)` and propagates
//! when the owner becomes live. Implicitly observable owners and references
//! outside registered functions make their targets live immediately. A
//! registered non-candidate owner does so only while implicitly observable
//! or count-live, because a count-dead owner's body never executes.
//! 3. **Implicit observability** is stable metadata for bindings whose runtime
//! value can be observed without a resolved reference in this AST. A symbol
//! here may have any number of references; the bit records that an observer
//! remains even if every reference disappears. The four channels are module
//! exports, Script-root bindings, Annex B aliases, and `using` disposal. This
//! metadata protects every count-based removal consumer, not only
//! declaration removal.
//! 4. **Analysis runs after scoping is flushed.** Every result is derived from
//! the settled resolved-reference lists, so AST rewrites need no parallel
//! collection hooks or behind-the-cursor repair log.
//!
//! ## Transform contract
//!
//! Functions are registered once during Normalize and the graph is recomputed
//! only when its settled inputs can change. Peephole transforms must preserve
//! four invariants:
//!
//! 1. **Do not create function declarations.** A new declaration would have no
//! entry in `function_by_scope` and therefore could not own references or
//! become a candidate. Removing an existing declaration is supported.
//! 2. **Do not move an existing reference across function owners.** Ownership
//! comes from `Reference::scope_id()` and the registered function-scope
//! ancestors. A transform that changes the nearest owning function must
//! instead drop and recreate the reference, so the dirty gate requests a
//! new analysis.
//! 3. **Do not create a path to a function already published as dead.** Deadness
//! is monotonic. Transforms may duplicate an already-live reference, but
//! must not make an unreachable binding reachable; debug builds assert that
//! a dead function never becomes live again.
//! 4. **Do not form a direct eval call.** Semantic flags propagate any direct
//! eval to the root scope, disabling the analysis for the whole program. The
//! analysis relies on that flag only ever clearing (dropping an eval
//! re-triggers analysis). An eval created after deadness was published could
//! reach a removed function. Debug builds assert this never happens.
//!
//! A transform that needs to violate one of these invariants must first extend
//! function registration or the dirty-analysis signal. Keeping this boundary
//! explicit is what lets the analysis run without per-transform collection
//! hooks or a reference-mint log.
//!
//! ES module bindings and CommonJS top-level bindings are local to their module
//! or wrapper. Script root bindings are visible to later scripts, so root
//! function declarations are implicitly observable, not graph candidates;
//! references in their bodies keep local candidates live. Local declarations
//! inside Script functions and strict blocks can still be removed.
//!
//! Treating a count-dead non-candidate owner's body as unreachable requires
//! that a declaration cannot execute without a resolved reference.
//! Registration therefore also excludes sloppy Annex B block functions whose
//! runtime var-alias write is not represented by their block-scoped symbol.
//!
//! ## Current limitations
//!
//! - Only function declarations are candidates. Mutual declarator cycles
//! (`const a = () => b(); const b = () => a();`) and class cycles are kept.
//! Making them candidates measured zero output bytes on real bundles but
//! caused most of the wrong-code hazards, and class removal has no stable
//! proof (`remove_unused_class` extracts static values, and heritage
//! classification can change between passes).
//! - Any direct eval propagates to the root scope and disables the analysis for
//! the whole program. There is no per-scope granularity.
//! - Sloppy unhoisted Annex B block functions are always kept, even when
//! nothing can reach them. Removing them requires modeling the runtime
//! var-alias write.
use oxc_allocator::{Allocator, ArenaVec, BitSet, GetAllocator};
use oxc_ast::ast::*;
#[cfg(debug_assertions)]
use oxc_ast_visit::{VisitJs, walk_js::walk_function};
use oxc_ecmascript::BoundNames;
use oxc_semantic::Scoping;
use oxc_span::SourceType;
use oxc_syntax::{reference::ReferenceId, scope::ScopeId, symbol::SymbolId};
use crate::{CompressOptions, CompressOptionsUnused, TraverseCtx};
/// Stable program-wide symbol facts plus the optional recursive-function graph.
///
/// This is always present for ES modules because exported-binding implicit
/// observability must protect count-based optimizations even when recursive
/// function removal is disabled. For CommonJS and Script sources it exists
/// whenever unused-declaration removal is enabled, and a `using` declaration
/// creates it lazily even otherwise, because disposal observes its binding
/// without an ordinary reference.
pub struct SymbolLiveness<'a> {
/// Bindings with a runtime observer independent of resolved references:
/// module exports, Script roots, Annex B aliases, and `using` disposal.
implicitly_observable: BitSet<'a>,
recursive_functions: Option<FunctionGraph<'a>>,
}
impl<'a> SymbolLiveness<'a> {
/// Return `None` when the configuration needs no symbol liveness: a
/// non-module source with unused-declaration removal disabled.
pub fn new_if_enabled(
source_type: SourceType,
options: &CompressOptions,
scoping: &Scoping,
allocator: &'a Allocator,
) -> Option<Self> {
let recursive_functions_enabled = options.unused != CompressOptionsUnused::Keep;
if !source_type.is_module() && !recursive_functions_enabled {
return None;
}
Some(Self::new(source_type, scoping, allocator))
}
/// Seed implicit observability from scoping; the function graph is created
/// lazily at first registration.
fn new(source_type: SourceType, scoping: &Scoping, allocator: &'a Allocator) -> Self {
let symbols_len = scoping.symbols_len();
let mut implicitly_observable = BitSet::new_in(symbols_len, allocator);
if source_type.is_script() {
for &symbol_id in scoping.get_bindings(scoping.root_scope_id()).values() {
implicitly_observable.set_bit(symbol_id.index());
}
}
Self { implicitly_observable, recursive_functions: None }
}
#[inline]
pub fn is_implicitly_observable(&self, symbol_id: SymbolId) -> bool {
self.implicitly_observable.contains(symbol_id.index())
}
#[inline]
pub fn function_is_dead(&self, symbol_id: SymbolId) -> bool {
self.recursive_functions.as_ref().is_some_and(|graph| graph.is_dead(symbol_id))
}
fn mark_implicitly_observable(&mut self, symbol_id: SymbolId) {
self.implicitly_observable.set_bit(symbol_id.index());
}
fn mark_bound_names<'b>(&mut self, node: &impl BoundNames<'b>) {
node.bound_names(&mut |ident| {
if let Some(symbol_id) = ident.symbol_id.get() {
self.mark_implicitly_observable(symbol_id);
}
});
}
fn register_function(
&mut self,
function: &Function<'_>,
source_type: SourceType,
scoping: &Scoping,
allocator: &'a Allocator,
) {
let Some(symbol_id) = function.id.as_ref().and_then(|id| id.symbol_id.get()) else {
return;
};
let Some(scope_id) = function.scope_id.get() else { return };
let binding_scope_id = scoping.symbol_scope_id(symbol_id);
// Annex B block functions also assign their function object to a
// var-like binding when the block executes. Semantic hoisting records
// that alias when possible, but duplicates and TypeScript declarations
// can retain a block-scoped symbol even though the runtime alias write
// still occurs. Such a declaration and writes to it can be observed
// with no resolved reference to its own symbol, so mark it implicitly
// observable instead of registering it.
let binding_scope_flags = scoping.scope_flags(binding_scope_id);
if !function.r#async
&& !function.generator
&& !binding_scope_flags.is_var()
&& !binding_scope_flags.is_strict_mode()
{
self.mark_implicitly_observable(symbol_id);
return;
}
if source_type.is_script() && binding_scope_id == scoping.root_scope_id() {
// Already seeded implicitly observable by `new`; only graph
// candidacy is skipped here.
return;
}
let graph =
self.recursive_functions.get_or_insert_with(|| FunctionGraph::new(scoping, allocator));
graph.register(scope_id, symbol_id);
}
fn analyze(&mut self, scoping: &Scoping) -> bool {
let Some(graph) = &mut self.recursive_functions else { return false };
graph.analyze(scoping, &self.implicitly_observable)
}
#[cfg(debug_assertions)]
fn dead_functions(&self) -> Option<&BitSet<'a>> {
self.recursive_functions.as_ref().map(|graph| &graph.dead)
}
}
/// The recursive-function reachability graph and its reused analysis buffers.
struct FunctionGraph<'a> {
/// Eligible functions the graph still tracks. [`Self::analyze`]
/// permanently stops tracking a non-dead candidate whose references all
/// lie outside registered functions, because it no longer needs graph
/// reachability. Graph-independent removal checks then handle it. Untracked
/// functions stay in `function_by_scope` and can still own references.
candidates: BitSet<'a>,
/// Maps each registered function declaration's own scope to its symbol.
/// Owner lookup walks the current semantic parent chain, so scopes inserted
/// or reparented after Normalize remain correct.
function_by_scope: ArenaVec<'a, Option<SymbolId>>,
/// Function symbols already published as unreachable. Declaration-removal
/// sites consume membership, but bits persist after AST removal so later
/// analyses can reject resurrection. Deadness is monotonic.
dead: BitSet<'a>,
scratch: GraphScratch<'a>,
}
impl<'a> FunctionGraph<'a> {
fn new(scoping: &Scoping, allocator: &'a Allocator) -> Self {
let symbols_len = scoping.symbols_len();
let function_by_scope =
ArenaVec::from_iter_in(std::iter::repeat_n(None, scoping.scopes_len()), &allocator);
Self {
candidates: BitSet::new_in(symbols_len, allocator),
function_by_scope,
dead: BitSet::new_in(symbols_len, allocator),
scratch: GraphScratch::new(symbols_len, allocator),
}
}
fn register(&mut self, scope_id: ScopeId, symbol_id: SymbolId) {
self.function_by_scope[scope_id.index()] = Some(symbol_id);
self.candidates.set_bit(symbol_id.index());
}
#[inline]
fn is_dead(&self, symbol_id: SymbolId) -> bool {
self.dead.contains(symbol_id.index())
}
/// Returns the nearest registered function whose body contains
/// `scope_id`. Code there only runs when that function is called. `None`
/// means the scope is not inside any registered function, so references
/// there make their target unconditionally live.
fn owner(&self, scoping: &Scoping, scope_id: ScopeId) -> Option<SymbolId> {
scoping
.scope_ancestors(scope_id)
.find_map(|scope_id| self.function_by_scope.get(scope_id.index()).copied().flatten())
}
/// Rebuild current reachability and report whether this run published any
/// newly dead function, requiring another peephole pass.
fn analyze(&mut self, scoping: &Scoping, implicitly_observable: &BitSet<'_>) -> bool {
if scoping.root_scope_flags().contains_direct_eval() {
// The minifier may remove direct eval but must never create one,
// so a graph that already published dead functions cannot get
// here.
debug_assert!(self.dead.is_empty(), "direct eval formed after liveness was published");
// In release builds, stop later passes from consuming more
// invalidated deadness. Declarations already removed cannot be
// restored if a transform violated the direct-eval contract.
self.dead.clear();
return false;
}
self.scratch.reset();
// Phase 1: classify every current reference to a candidate. Seed
// unconditional liveness, store candidate-owned dependencies, and
// identify functions that no longer need graph reachability.
for bit in self.candidates.ones() {
let target = SymbolId::from_usize(bit);
// Implicitly observable candidates (e.g. exported functions) are
// unconditionally live regardless of their references.
if implicitly_observable.contains(bit) {
self.scratch.mark_live(target);
}
let mut has_registered_function_owner = false;
for &reference_id in scoping.get_resolved_reference_ids(target) {
let reference = scoping.get_reference(reference_id);
let Some(owner) = self.owner(scoping, reference.scope_id()) else {
self.scratch.mark_live(target);
continue;
};
has_registered_function_owner = true;
if implicitly_observable.contains(owner.index()) {
// A reference owned by a permanently live function keeps
// its target live unconditionally. Avoid storing and
// sorting the common implicitly observable owner case; the
// result is the same.
self.scratch.mark_live(target);
continue;
}
if self.candidates.contains(owner.index()) {
self.scratch.owned_references.push((owner, target));
} else if !scoping.symbol_is_unused(owner) {
// A registered non-candidate owner keeps the target live
// only while its body can still execute.
self.scratch.mark_live(target);
}
}
if !has_registered_function_owner && !self.dead.contains(bit) {
self.scratch.candidates_to_untrack.set_bit(bit);
}
}
// Phase 2: untrack owners that no longer need graph reachability.
// `owned_references` was collected before `candidates_to_untrack` was
// known. Stored owners are not implicitly observable, so preserve
// their targets when ordinary reference counts still make the owner
// executable.
for index in 0..self.scratch.owned_references.len() {
let (owner, target) = self.scratch.owned_references[index];
let owner_leaves_graph = self.scratch.candidates_to_untrack.contains(owner.index());
let owner_stays_live_by_count = !scoping.symbol_is_unused(owner);
if owner_leaves_graph && owner_stays_live_by_count {
self.scratch.mark_live(target);
}
}
// Future analyses treat these functions as registered non-candidate
// owners.
for bit in self.scratch.candidates_to_untrack.ones() {
self.candidates.unset_bit(bit);
}
#[cfg(debug_assertions)]
for bit in self.dead.ones() {
assert!(
self.candidates.contains(bit),
"dead function symbol {bit} was untracked; dead candidates must remain graph \
candidates",
);
}
// Phase 3: propagate from unconditional live seeds through references
// owned by live candidates.
self.scratch.propagate_liveness(&self.candidates);
#[cfg(debug_assertions)]
for bit in self.dead.ones() {
assert!(
!self.scratch.live.contains(bit),
"function liveness resurrected dead symbol {bit}; transforms must not create a \
new path to a previously unreachable binding",
);
}
// Phase 4: publish newly unreachable candidates. Existing dead bits
// remain as tombstones for the transform contract checks above.
let mut published_new_dead = false;
for bit in self.candidates.ones() {
if !self.scratch.live.contains(bit) && !self.dead.contains(bit) {
self.dead.set_bit(bit);
published_new_dead = true;
}
}
published_new_dead
}
}
struct GraphScratch<'a> {
/// All functions proven live during the current analysis.
live: BitSet<'a>,
/// Live functions whose owned references have not been propagated yet.
live_worklist: ArenaVec<'a, SymbolId>,
/// `(owner, target)` for each reference to `target` inside `owner`'s body.
owned_references: ArenaVec<'a, (SymbolId, SymbolId)>,
/// Candidates that no longer need graph reachability and will stop being
/// tracked after reference collection.
candidates_to_untrack: BitSet<'a>,
}
impl<'a> GraphScratch<'a> {
fn new(symbols_len: usize, allocator: &'a Allocator) -> Self {
Self {
live: BitSet::new_in(symbols_len, allocator),
live_worklist: ArenaVec::new_in(&allocator),
owned_references: ArenaVec::new_in(&allocator),
candidates_to_untrack: BitSet::new_in(symbols_len, allocator),
}
}
fn reset(&mut self) {
self.live.clear();
self.live_worklist.clear();
self.owned_references.clear();
self.candidates_to_untrack.clear();
}
fn mark_live(&mut self, symbol_id: SymbolId) {
let bit = symbol_id.index();
if !self.live.contains(bit) {
self.live.set_bit(bit);
self.live_worklist.push(symbol_id);
}
}
/// Marks the direct targets referenced by `owner` as live.
/// Requires `owned_references` to be sorted by owner.
fn mark_targets_live(&mut self, owner: SymbolId) {
let mut index = self
.owned_references
.partition_point(|&(reference_owner, _)| reference_owner.index() < owner.index());
while let Some((reference_owner, target)) = self.owned_references.get(index).copied() {
if reference_owner != owner {
break;
}
self.mark_live(target);
index += 1;
}
}
fn propagate_liveness(&mut self, candidates: &BitSet<'_>) {
// References owned by functions no longer in the graph cannot
// propagate. Targets of count-live owners were seeded before this call.
self.owned_references.retain(|(owner, _)| candidates.contains(owner.index()));
// Group references by owner for `mark_targets_live`.
self.owned_references.sort_unstable_by_key(|&(owner, _)| owner.index());
while let Some(owner) = self.live_worklist.pop() {
if !candidates.contains(owner.index()) {
// The owner may have been seeded before it was untracked; its
// targets were already handled by graph-independent liveness.
continue;
}
self.mark_targets_live(owner);
}
}
}
/// Normalize hook: register a function declaration as a potential graph
/// candidate.
pub fn register_function(function: &Function<'_>, ctx: &mut TraverseCtx<'_>) {
if !function.is_declaration() {
return;
}
if ctx.state.options.unused == CompressOptionsUnused::Keep {
return;
}
let allocator = ctx.allocator();
let TraverseCtx { state, scoping, .. } = ctx;
if let Some(liveness) = &mut state.symbol_liveness {
liveness.register_function(function, state.source_type, scoping.scoping(), allocator);
}
}
/// Normalize hook: record resource bindings observed later by explicit
/// resource management. Disposal reads the bound value through runtime state,
/// not through a resolved identifier reference in the AST.
pub fn register_using_declaration(
declaration: &VariableDeclaration<'_>,
ctx: &mut TraverseCtx<'_>,
) {
if !declaration.kind.is_using() {
return;
}
let source_type = ctx.state.source_type;
let allocator = ctx.allocator();
let TraverseCtx { state, scoping, .. } = ctx;
let liveness = state
.symbol_liveness
.get_or_insert_with(|| SymbolLiveness::new(source_type, scoping.scoping(), allocator));
liveness.mark_bound_names(declaration);
}
/// Normalize hook: record runtime bindings exposed by a named export.
pub fn register_named_export(declaration: &ExportNamedDeclaration<'_>, ctx: &mut TraverseCtx<'_>) {
let TraverseCtx { state, scoping, .. } = ctx;
let Some(liveness) = &mut state.symbol_liveness else { return };
if !declaration.export_kind.is_type()
&& let Some(inner) = &declaration.declaration
{
liveness.mark_bound_names(inner);
}
if declaration.source.is_some() || declaration.export_kind.is_type() {
return;
}
for specifier in &declaration.specifiers {
if specifier.export_kind.is_type() {
continue;
}
let ModuleExportName::IdentifierReference(local) = &specifier.local else { continue };
let Some(reference_id) = local.reference_id.get() else { continue };
let reference = scoping.scoping().get_reference(reference_id);
if reference.flags().is_type_only() {
continue;
}
if let Some(symbol_id) = reference.symbol_id() {
liveness.mark_implicitly_observable(symbol_id);
}
}
}
/// Normalize hook: record the local binding of a named default function or
/// class declaration. `export default identifier` is intentionally excluded:
/// it exports the evaluated value, not subsequent writes to that local binding.
pub fn register_default_export(
declaration: &ExportDefaultDeclaration<'_>,
ctx: &mut TraverseCtx<'_>,
) {
let symbol_id = match &declaration.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(function) => {
function.id.as_ref().and_then(|id| id.symbol_id.get())
}
ExportDefaultDeclarationKind::ClassDeclaration(class) => {
class.id.as_ref().and_then(|id| id.symbol_id.get())
}
_ => None,
};
if let Some(symbol_id) = symbol_id
&& let Some(liveness) = &mut ctx.state.symbol_liveness
{
liveness.mark_implicitly_observable(symbol_id);
}
}
/// Whether pruning this pass's dead references can change a graph input.
///
/// Reachability is immediately sensitive to resolved-reference-list changes of
/// candidate target symbols. It also checks whether registered non-candidate
/// owners are count-live. Removing the last reference to such an owner can only
/// make the current result conservatively stale: direct eval disables the
/// analysis, while implicitly observable owners take a separate branch. A
/// later pass removes the count-dead owner; removing its body then drops
/// candidate-target references and triggers recomputation. Fresh references
/// cannot resurrect a published dead function, so additions alone need no
/// recompute. Scope-only rewrites preserve the nearest function owner.
pub fn dead_references_affect_analysis(ctx: &TraverseCtx<'_>) -> bool {
let Some(liveness) = &ctx.state.symbol_liveness else { return false };
let Some(graph) = &liveness.recursive_functions else { return false };
// The analysis is disabled while the root scope contains direct eval, so
// removed references cannot affect it. The flag may be stale if this pass
// dropped the last eval (flags refresh after this check), but that drop
// also sets `eval_dropped`, which forces the recompute anyway.
if ctx.scoping().root_scope_flags().contains_direct_eval() {
return false;
}
ctx.state.dirty.dead_refs.ones().any(|bit| {
ctx.scoping()
.get_reference(ReferenceId::from_usize(bit))
.symbol_id()
.is_some_and(|symbol_id| graph.candidates.contains(symbol_id.index()))
})
}
/// Assert that the completed pass removed every previously published dead
/// declaration, then optionally analyze settled semantic references and
/// publish newly dead functions. Called only by the end-of-pass sequence after
/// scoping has been flushed.
pub fn analyze<'a>(program: &Program<'a>, ctx: &mut TraverseCtx<'a>, recompute: bool) -> bool {
#[cfg(not(debug_assertions))]
let _ = program;
#[cfg(debug_assertions)]
if let Some(dead) = ctx.state.symbol_liveness.as_ref().and_then(SymbolLiveness::dead_functions)
{
debug_assert_dead_function_declarations_removed(program, ctx.scoping(), dead);
}
if !recompute {
return false;
}
let TraverseCtx { state, scoping, .. } = ctx;
state.symbol_liveness.as_mut().is_some_and(|liveness| liveness.analyze(scoping.scoping()))
}
/// Debug contract for previously published graph deadness: it is consumed only
/// at function-declaration sites, and every such declaration must now be gone.
#[cfg(debug_assertions)]
fn debug_assert_dead_function_declarations_removed(
program: &Program<'_>,
scoping: &Scoping,
dead: &BitSet<'_>,
) {
if dead.is_empty() {
return;
}
DeadFunctionSweep { scoping, dead }.visit_program(program);
}
#[cfg(debug_assertions)]
struct DeadFunctionSweep<'s, 'd, 'a> {
scoping: &'s Scoping,
dead: &'d BitSet<'a>,
}
#[cfg(debug_assertions)]
impl<'a> VisitJs<'a> for DeadFunctionSweep<'_, '_, '_> {
fn visit_function(&mut self, function: &Function<'a>, flags: oxc_syntax::scope::ScopeFlags) {
if function.is_declaration()
&& let Some(symbol_id) = function.id.as_ref().and_then(|id| id.symbol_id.get())
{
assert!(
!self.dead.contains(symbol_id.index()),
"dead function `{}` survived the pass after its deadness was published",
self.scoping.symbol_name(symbol_id),
);
}
walk_function(self, function, flags);
}
}
-3
View File
@@ -42,9 +42,6 @@ pub struct SymbolValue<'a> {
/// resolve the value through `initialized_constant`.
pub implicit_undefined: bool,
/// Symbol is exported.
pub exported: bool,
pub read_references_count: u32,
pub write_references_count: u32,
@@ -290,18 +290,6 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
falsy_init: bool,
init_absent: bool,
) {
let mut exported = false;
if self.scoping.current_scope_id() == self.scoping().root_scope_id() {
for ancestor in self.ancestors() {
if ancestor.is_export_named_declaration()
|| ancestor.is_export_all_declaration()
|| ancestor.is_export_default_declaration()
{
exported = true;
}
}
}
let mut read_references_count = 0;
let mut write_references_count = 0;
let mut member_write_target_read_count = 0;
@@ -344,7 +332,6 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
let symbol_value = SymbolValue {
initialized_constant,
implicit_undefined,
exported,
read_references_count,
write_references_count,
member_write_target_read_count,
+10
View File
@@ -52,6 +52,16 @@ pub(crate) fn test_options(source_text: &str, expected: &str, options: &Compress
test_options_source_type(source_text, expected, SourceType::mjs(), options);
}
/// Assert one capped compression run without the usual idempotency check.
/// A deliberately low `max_iterations` may conservatively retain code that a
/// second independent run can remove.
#[track_caller]
pub(crate) fn test_options_once(source_text: &str, expected: &str, options: &CompressOptions) {
let actual = run(source_text, SourceType::mjs(), Some(options.clone()));
let expected = run(expected, SourceType::mjs(), None);
assert_eq!(actual, expected, "\nfor source\n{source_text}\nexpect\n{expected}\ngot\n{actual}");
}
#[track_caller]
pub(crate) fn test_same_options(source_text: &str, options: &CompressOptions) {
test_options(source_text, source_text, options);
@@ -3,8 +3,7 @@ use rustc_hash::FxHashSet;
use oxc_allocator::Allocator;
use oxc_codegen::Codegen;
use oxc_minifier::CompressOptions;
use oxc_minifier::Compressor;
use oxc_minifier::{CompressOptions, Compressor, TreeShakeOptions};
use oxc_parser::Parser;
use oxc_span::SourceType;
@@ -26,11 +25,12 @@ fn test(source_text: &str, expected: &str) {
let f = "('production' == 'development')";
let source_text = source_text.cow_replace("true", t);
let source_text = source_text.cow_replace("false", f);
let source_type = SourceType::default();
let result = run(&source_text, source_type, Some(CompressOptions::dce()));
let expected = run(expected, source_type, None);
assert_eq!(result, expected, "\nfor source\n{source_text}\nexpect\n{expected}\ngot\n{result}");
test_with_options_source_type(
&source_text,
expected,
SourceType::default(),
CompressOptions::dce(),
);
}
#[track_caller]
@@ -38,12 +38,38 @@ fn test_same(source_text: &str) {
test(source_text, source_text);
}
#[track_caller]
fn test_source_type(source_text: &str, expected: &str, source_type: SourceType) {
test_with_options_source_type(source_text, expected, source_type, CompressOptions::dce());
}
#[track_caller]
fn test_same_source_type(source_text: &str, source_type: SourceType) {
test_source_type(source_text, source_text, source_type);
}
#[track_caller]
fn test_with_options(source_text: &str, expected: &str, options: CompressOptions) {
let source_type = SourceType::default();
let result = run(source_text, source_type, Some(options));
test_with_options_source_type(source_text, expected, SourceType::default(), options);
}
#[track_caller]
fn test_with_options_source_type(
source_text: &str,
expected: &str,
source_type: SourceType,
options: CompressOptions,
) {
let result = run(source_text, source_type, Some(options.clone()));
let expected = run(expected, source_type, None);
assert_eq!(result, expected, "\nfor source\n{source_text}\nexpect\n{expected}\ngot\n{result}");
// Check idempotency.
let second = run(&result, source_type, Some(options));
assert_eq!(
result, second,
"\nidempotency for source\n{source_text}\ngot\n{result}\nthen\n{second}"
);
}
#[test]
@@ -771,3 +797,128 @@ fn dce_remove_unreachable_after_terminating_statement() {
"export function f(c) {\n\t{\n\t\tif (c) return g();\n\t\tfunction g() {\n\t\t\treturn 1;\n\t\t}\n\t}\n\treturn tail();\n}",
);
}
// #13105: dead recursive/cyclic function declarations must also drop in
// dce-only mode (rolldown's per-module treeshake preprocess). Self-recursive
// function-valued declarators use a local removal-site check; mutual
// declarator and class cycles are kept because graph candidacy is function
// declarations only.
#[test]
fn dce_recursive_unused_functions() {
test("function f() { f() }", "");
test("function c() { d() } function d() { c() }", "");
test("var f = function() { f() }", "");
test("const f = () => f()", "");
// Cycle whose only external reference sits in dead code: needs the mid-loop
// recompute trigger (pass 2), not just the initial compute.
test("if (false) c(); function c() { d() } function d() { c() }", "");
// Declarator and class cycles are kept (functions-only candidacy).
test_same("const a = () => b();\nconst b = () => a();");
test_same("class A {\n\tm() {\n\t\tnew B();\n\t}\n}\nclass B {\n\tm() {\n\t\tnew A();\n\t}\n}");
// Live references keep the cycle live.
test_same("function f() {\n\tf();\n}\nconsole.log(f);");
test_same("export function f() {\n\tf();\n}");
// Removing a dead cycle can zero an exported sibling redeclaration's
// ordinary read count; stable export observability protects its writes.
test(
"export var f; var f = 0; function d1() { console.log(f); d2() } function d2() { d1() } f = 1;",
"export var f;\nvar f = 0;\nf = 1;",
);
// For-head bindings need no special lifecycle state.
test(
"var f = 1; function d1() { f; d2() } function d2() { d1() } if (false) for (var f of xs) {} export {};",
"export {};",
);
}
#[test]
#[ignore = "TODO: extend recursive reachability to mutual declarators and classes"]
fn dce_recursive_unused_mutual_declarators_and_classes() {
test("const a = () => b(); const b = () => a();", "");
test("class A { m() { new B() } } class B { m() { new A() } }", "");
}
#[test]
fn dce_recursive_unused_functions_in_commonjs_and_script() {
test_source_type("var f = function() { f() }", "", SourceType::cjs());
test_source_type(
"function c() { d() } function d() { c() } console.log('k');",
"console.log('k');",
SourceType::cjs(),
);
test_source_type("{ function f() { f() } }", "", SourceType::cjs());
test_source_type(
"if (false) g(); function g() { f() } function f() { f() }",
"",
SourceType::cjs(),
);
test_source_type(
"function outer() { function c() { d() } function d() { c() } return 1 }",
"function outer() { return 1 }",
SourceType::script(),
);
test_source_type(
"function outer() { const f = () => f(); return 1 }",
"function outer() { return 1 }",
SourceType::script(),
);
test_same_source_type("function f() { f() }", SourceType::script());
test_same_source_type("var f = () => f()", SourceType::script());
test_same_source_type("{ function f() { f() } }", SourceType::script());
test_same_source_type("function f() { f() } module.exports = f;", SourceType::cjs());
}
#[test]
fn dce_keeps_sloppy_duplicate_block_functions() {
let source =
"{ function f() { return 1 } } { function f() { return f } } console.log(typeof f());";
for source_type in [SourceType::script(), SourceType::cjs()] {
test_same_source_type(source, source_type);
}
test_same_source_type(
"{ function f() { return f } } { function f() { return f } } console.log(typeof f());",
SourceType::ts().with_script(true),
);
// Strict block functions have no Annex B var alias and remain removable.
test_source_type("'use strict'; { function f() { f() } }", "'use strict';", SourceType::cjs());
test_source_type(
"'use strict'; { function f() { f() } }",
"'use strict';",
SourceType::ts().with_script(true),
);
}
#[test]
fn dce_keeps_script_root_var_in_nested_statement_after_cycle_removed() {
let source = "function outer() { function d1() { return x + d2() } function d2() { return d1() } return 1 } outer(); switch (1) { case 1: var x = 42; }";
let expected = "function outer() { return 1 } switch (1) { case 1: var x = 42; }";
test_source_type(source, expected, SourceType::script());
// CommonJS top-level vars are wrapper-local, so ordinary counts may remove them.
test_source_type("{ var x = 42; }", "", SourceType::cjs());
}
#[test]
fn dce_keeps_implicitly_observable_bindings() {
let options = CompressOptions {
treeshake: TreeShakeOptions {
property_write_side_effects: false,
..TreeShakeOptions::default()
},
..CompressOptions::dce()
};
let annex_source = "function outer() { { function f() {} } { function f() {} f.x = 1; function d1() { consume(f); d2() } function d2() { d1() } } console.log(f.x); } outer();";
let annex_expected = "function outer() { { function f() {} } { function f() {} f.x = 1; } console.log(f.x); } outer();";
for source_type in [SourceType::script(), SourceType::cjs()] {
test_with_options_source_type(annex_source, annex_expected, source_type, options.clone());
}
test_with_options(
"{ using resource = { [Symbol.dispose]() { console.log(this.x) } }; resource.x = 1; function d1() { consume(resource); d2() } function d2() { d1() } }",
"{ using resource = { [Symbol.dispose]() { console.log(this.x) } }; resource.x = 1; }",
options,
);
}
@@ -36,8 +36,8 @@ fn test_void_ident() {
// Leak regression: Normalize runs before the peephole fixed-point loop, but
// `PassDirty` is live from `MinifierState::new`, so Normalize's typed-helper
// drops are recorded like any pass's and consumed by the driver's pre-loop
// `flush_pass_dirty`. A leaked read makes `x` look referenced, blocking
// drops are recorded like any pass's and consumed by the pre-loop `end_pass`
// flush. A leaked read makes `x` look referenced, blocking
// unused-declaration removal.
#[test]
fn test_void_ident_does_not_leak_reference() {
@@ -1,8 +1,9 @@
use oxc_span::SourceType;
use crate::{
CompressOptions, TreeShakeOptions, test_options, test_options_source_type, test_same_options,
test_same_options_source_type, test_same_smallest, test_smallest,
CompressOptions, CompressOptionsUnused, TreeShakeOptions, test_options, test_options_once,
test_options_source_type, test_same_options, test_same_options_source_type, test_same_smallest,
test_smallest,
};
// Leak regression: dropping an unused declarator must walk the whole
@@ -356,11 +357,198 @@ fn keep_in_script_mode() {
test_options_source_type("class C {}", "class C {}", source_type, &options);
}
// ---- Graph removal of dead recursive cycles ----
// #13105: a declaration whose every reference lives inside its own body (or
// inside the bodies of a cycle it belongs to) can never execute — no live
// code can reach it, so the whole group is removable. Reference counting
// alone can't see this: the internal references keep the count above zero.
#[test]
fn remove_recursive_unused_function_declaration() {
// Self-recursion.
test_smallest("function f() { f() }", "");
// Side effects inside the dead body never run.
test_smallest("function f() { console.log(1); f() }", "");
// Mutual recursion.
test_smallest("function c() { d() } function d() { c() }", "");
// Self-reference as a value.
test_smallest("function f() { return f }", "");
test_smallest("function f() { g(f) }", "");
// The cycle's only external reference is inside dead code.
test_smallest("if (false) c(); function c() { d() } function d() { c() }", "");
}
// Ownership is determined from each reference's current semantic scope, not
// from traversal frames. References nested in unregistered scopes still
// belong to their nearest enclosing function declaration.
#[test]
fn remove_recursive_functions_through_nested_scope_kinds() {
test_smallest(
"function a(p = b) { { return () => function () { return class { m() { b() } } } } } function b() { a() }",
"",
);
}
#[test]
fn keep_recursive_functions_referenced_outside_registered_functions() {
test_smallest(
"function a() { b() } function b() { a() } use(() => a, function () { b() }, class { m() { a() } });",
"function a() {\n\tb();\n}\nfunction b() {\n\ta();\n}\nuse(() => a, function() {\n\tb();\n}, class {\n\tm() {\n\t\ta();\n\t}\n});",
);
}
#[test]
fn remove_recursive_unused_nested_in_live_function() {
// Dead recursion inside a used function: statement-level tree shaking
// (rolldown's linker) cannot see inside bodies, so this must be handled
// here.
test_smallest(
"function live() { function inner() { inner() } return 1; } g(live());",
"function live() { return 1; } g(live());",
);
}
// ---- Site-local self-recursive declarators ----
#[test]
fn remove_self_recursive_function_valued_declarators() {
test_smallest("var f = function() { f() };", "");
test_smallest("const f = () => f();", "");
test_smallest("let f = function(value = f()) {};", "");
test_smallest("let f = (value = f()) => value;", "");
}
#[test]
fn keep_reachable_self_recursive_function_valued_declarators() {
test_same_smallest("const f = function() { f() }; use(f);");
test_same_smallest("let f = () => f(); f = other;");
test_same_smallest("export const f = () => f();");
test_same_smallest("const f = (effect(), () => f());");
// A script-level `var` is externally observable even when its declaration
// is nested in a block and visited from that block's scope.
test_options_source_type(
"{ var f = function() { f() } }",
"var f = function() { f() };",
SourceType::cjs().with_script(true),
&CompressOptions::smallest(),
);
}
// A for initializer is an actual declarator removal site, so it can use the
// same local self-reference check without becoming a graph candidate.
#[test]
fn remove_self_recursive_for_init_declarator() {
test_smallest("for (let f = () => f();;) break;", "for (;;) break;");
}
// ---- Non-candidates: declarator and class cycles ----
// Mutual declarator and class cycles are deliberately kept because only
// function declarations participate in graph reachability. These tests pin
// the currently unsupported shapes.
#[test]
fn keep_recursive_declarator_and_class_cycles() {
// const arrow cycle.
test_smallest(
"const a = () => b(); const b = () => a();",
"const a = () => b(), b = () => a();",
);
// Class cycle with side-effect-free evaluation.
test_same_smallest(
"class A {\n\tm() {\n\t\tnew B();\n\t}\n}\nclass B {\n\tm() {\n\t\tnew A();\n\t}\n}",
);
// Mixed function / const arrow / class cycle: the non-function members
// keep the function member live, so nothing is removed.
test_smallest(
"function a() { b() } const b = () => { new C() }; class C { m() { a() } }",
"function a() {\n\tb();\n}\nconst b = () => {\n\tnew C();\n};\nclass C {\n\tm() {\n\t\ta();\n\t}\n}",
);
}
#[test]
fn keep_recursive_multi_declarator_cycle() {
// The declarator member of the cycle keeps the function member live, so
// the cycle survives even after the used sibling declarator is inlined.
test_smallest(
"const a = () => b(), keep = 1; function b() { a() } console.log(keep);",
"const a = () => b();\nfunction b() {\n\ta();\n}\nconsole.log(1);",
);
}
// This declarator is outside every registered function, so its reference makes
// the target function unconditionally live even though the declaration sits in
// a bare statement slot.
#[test]
fn keep_declarator_cycle_in_bare_statement_slot() {
test_same_smallest("function a() {\n\tb();\n}\nif (g) var b = a;");
}
// Future extension: mutual declarator cycles need graph participation rather
// than the removal-site-local check used for self-recursive initializers.
#[test]
#[ignore = "TODO: extend recursive reachability to mutual variable declarators"]
fn remove_recursive_unused_mutual_declarator_cycles() {
test_smallest("const a = () => b(); const b = () => a();", "");
test_smallest(
"const a = () => b(), keep = 1; function b() { a() } console.log(keep);",
"console.log(1);",
);
}
// Future extension: class evaluation needs a stable removability proof before
// classes can participate in the graph. These are the side-effect-free shapes
// that should become removable once that proof exists.
#[test]
#[ignore = "TODO: extend recursive reachability to class declarations"]
fn remove_recursive_unused_class_cycles() {
test_smallest("class A { m() { new B() } } class B { m() { new A() } }", "");
test_smallest("function a() { b() } const b = () => { new C() }; class C { m() { a() } }", "");
test_smallest("function F() {} class A extends F { m() { new A() } }", "");
test_smallest("function F() {} class A extends (0 || F) { m() { new A() } }", "");
}
// ---- References from live code ----
#[test]
fn keep_recursive_function_with_live_references() {
// A read from live code keeps the cycle live.
test_same_smallest("function f() { f() } console.log(f);");
// A write from live code also keeps it live (dropping the function would
// leave `f = null` assigning to a missing binding).
test_same_smallest("function f() { f() } f = null;");
// Direct eval in the declaring scope blocks removal.
test_same_smallest("function o() { function f() { f() } eval('x') } o();");
// Root direct eval disables publication before the first pass.
test_same_smallest("eval('x');\nfunction f() {\n\tf();\n}");
}
#[test]
fn keep_recursive_cycle_with_side_effectful_evaluation() {
// The side-effectful initializer survives, and its reference to `b`
// keeps the cycle live.
test_smallest(
"const a = (console.log(1), () => b()); const b = () => a();",
"const a = (console.log(1), () => b()), b = () => a();",
);
// Side-effectful heritage keeps the class cycle.
test_same_smallest(
"class A extends (console.log(1), Object) { m() { new B() } } class B { m() { new A() } }",
);
// A PURE static value still keeps the class cycle: `remove_unused_class`
// extracts every present static value, so removal would not be clean —
// the extracted `B` would reference a removed cycle member (see
// `classify_class_removability`).
test_same_smallest("class A { static x = B; m() { new B() } } class B { m() { new A() } }");
}
// ---- Class heritage classification ----
#[test]
fn keep_class_cycle_with_wrapped_arrow_heritage() {
// `classify_class_removability` must see the arrow heritage through a
// pure sequence/paren wrapper, so the classification cannot change when
// a fold surfaces the literal arrow between passes.
// The heritage wrapper folds to a literal arrow, but extending an arrow is
// a guaranteed TypeError, so count-based class removal must still keep the
// declaration and its cycle.
test_smallest(
"class A extends (0, () => {}) { m() { new B() } } class B { m() { new A() } } console.log(1);",
"class A extends (() => {}) {\n\tm() {\n\t\tnew B();\n\t}\n}\nclass B {\n\tm() {\n\t\tnew A();\n\t}\n}\nconsole.log(1);",
@@ -395,6 +583,414 @@ fn keep_class_with_tdz_or_undefined_heritage() {
test_same_smallest("var B = class {};\nclass A extends B {\n\tm() {\n\t\tnew A();\n\t}\n}");
}
#[test]
fn keep_class_cycle_with_hoisted_function_heritage() {
// The class is not a candidate, and its heritage reference keeps `F` live.
test_same_smallest("function F() {}\nclass A extends F {\n\tm() {\n\t\tnew A();\n\t}\n}");
}
// Classes remain count-managed. A logical fold (`0 || Y` -> `Y`) may surface
// risky heritage mid-pass, but it must not make the surrounding class cycle
// removable. Everything is kept; only the fold itself changes the output.
#[test]
fn keep_class_cycle_with_fold_unstable_heritage() {
// Risky identifier (initialized var) inside a foldable wrapper.
test_smallest(
"class B { m() { new A() } } var Y = class {}; class A extends (0 || Y) { [B]() {} }",
"class B {\n\tm() {\n\t\tnew A();\n\t}\n}\nvar Y = class {};\nclass A extends Y {\n\t[B]() {}\n}",
);
// Arrow inside the same wrapper (`extends` an arrow is a guaranteed
// TypeError, so surfacing it also flips to `Keep`).
test_smallest(
"class B { m() { new A() } } class A extends (0 || (() => {})) { [B]() {} }",
"class B {\n\tm() {\n\t\tnew A();\n\t}\n}\nclass A extends (() => {}) {\n\t[B]() {}\n}",
);
}
#[test]
fn keep_class_cycle_with_wrapped_heritage() {
// The `0 || F` fold still surfaces `F`; the class is kept either way
// (classes are not candidates), and the heritage reference keeps `F` live.
test_smallest(
"function F() {}\nclass A extends (0 || F) {\n\tm() {\n\t\tnew A();\n\t}\n}",
"function F() {}\nclass A extends F {\n\tm() {\n\t\tnew A();\n\t}\n}",
);
}
// Exported classes are externally observable, and references in class method
// scopes keep function candidates live because classes are not graph candidates.
#[test]
fn keep_exported_class_cycle() {
test_same_smallest("export class A { m() { new B() } } class B { m() { new A() } }");
test_same_smallest("export default class A { m() { new B() } } class B { m() { new A() } }");
}
// Script-root class bindings are cross-script observable, like vars. The
// module-mode keep for this cycle is pinned in
// `keep_recursive_declarator_and_class_cycles`.
#[test]
fn keep_recursive_class_in_script_mode_top_level() {
let options = CompressOptions::smallest();
test_same_options_source_type(
"class A { m() { new B() } } class B { m() { new A() } }",
SourceType::cjs().with_script(true),
&options,
);
}
// ---- Function/var redeclarations ----
// A symbol shared by a graph-eligible function declaration and a count-only
// variable declaration must stay consistent at both removal sites.
#[test]
fn recursive_function_with_var_redeclaration() {
// The array init has a specialized (residue-leaving) handler, so the
// variable site is count-only and its references come from live code: the
// function declaration can be removed while the initializer residue stays.
test_smallest("function f() { f() } var f = [g()];", "g();");
test_same_smallest("function f() { f() } var f = [f];");
}
#[test]
fn recursive_function_var_redeclaration_converges_on_count_pass() {
test_smallest("function f() { f() } var f;", "");
// The first pass removes the function using published graph deadness.
// Its body reference is pruned only at the following flush, so a capped
// run conservatively retains the sibling `var` declaration.
let options = CompressOptions { max_iterations: Some(0), ..CompressOptions::smallest() };
test_options_once("function f() { f() } var f;", "var f;", &options);
}
// ---- Export observability ----
#[test]
fn module_export_observability_kinds() {
test_same_smallest("export function f() {\n\tf();\n}");
test_same_smallest("function f() {\n\tf();\n}\nexport { f };");
test_same_smallest("export default function f() {\n\tf();\n}");
// A default identifier contributes an ordinary evaluated-value reference;
// it does not add stable observability metadata to the local binding.
test_same_smallest("function f() {\n\tf();\n}\nexport default f;");
let options = CompressOptions::smallest();
test_options_source_type(
"function f() { f() } export type { f };",
"export type { f };",
SourceType::ts(),
&options,
);
}
// Export observability is stable symbol metadata. It protects a binding even
// when another declaration of the same symbol supplies its runtime value.
#[test]
fn keep_recursive_cycle_with_exported_redeclaration() {
// `export var f;` carries no reference, but importers observe the
// binding: removing the initializing redeclaration would export
// undefined.
test_same_smallest("export var f; var f = function() { setTimeout(f) };");
}
// `export var f;` carries no ordinary reference. Stable export observability
// must protect a sibling initializer after removal of the dead cycle that held
// its last in-module read.
#[test]
fn keep_exported_var_initializer_when_a_dead_cycle_held_its_only_reference() {
test_smallest(
"export var f;\nvar f = function () { return 'F' };\nfunction d1() { console.log(f); return d2() }\nfunction d2() { return d1() }",
"export var f;\nvar f = function() {\n\treturn 'F';\n};",
);
}
// Every count-based consumer shares the same export observability predicate.
// Deleting the dead cycle removes the last ordinary read of `f`, but assignments
// and member writes remain observable through the exported binding.
#[test]
fn keep_exported_binding_writes_when_a_dead_cycle_held_its_other_reads() {
test_smallest(
"export var f; var f = 0; function d1() { console.log(f); d2() } function d2() { d1() } f = 1;",
"export var f;\nvar f = 0;\nf = 1;",
);
test_smallest(
"export var f; var f = {}; function d1() { console.log(f); d2() } function d2() { d1() } f.x = 1;",
"export var f;\nvar f = {};\nf.x = 1;",
);
}
// Export observability also gates `is_expression_result_unused`
// (`substitute_alternate_syntax`). A dead cycle's removal can discard all
// ordinary reads while importers still observe the binding. Without the
// stable export bit, the empty async/generator IIFE arms
// collapse the initializer to `void 0` — importers would read `undefined`
// instead of a Promise / Generator object. (The pure-arrow arms share the
// gate but their shapes dissolve on pass 1 via `try_take_iife_body`,
// before the count can zero; the async/generator family keeps its shape,
// which is what makes this reachable.)
#[test]
fn keep_exported_iife_init_when_a_dead_cycle_held_its_only_reference() {
test_smallest(
"export var f; var f = (async () => {})(); function d1() { f(); return d2() } function d2() { return d1() }",
"export var f;\nvar f = (async () => {})();",
);
test_smallest(
"export var g; var g = (function* () {})(); function d1() { g; return d2() } function d2() { return d1() }",
"export var g;\nvar g = (function* () {})();",
);
}
// Export observability must not leak through an arrow: `export default () =>
// { function f() {} }` declares an ordinary candidate, not an exported
// binding.
#[test]
fn export_observability_ignores_declarations_inside_exported_arrow() {
test_smallest(
"export default () => { function nested() {} nested(); }; function dead1() { dead2() } function dead2() { dead1() }",
"export default () => {};",
);
}
// ---- CommonJS and script sources ----
#[test]
fn analyze_commonjs_and_script_local_functions() {
let options = CompressOptions::smallest();
let cycle = "function c() {\n\td();\n}\nfunction d() {\n\tc();\n}\nconsole.log(\"k\");";
test_options_source_type(cycle, "console.log(\"k\");", SourceType::cjs(), &options);
test_options_source_type("{ function f() { f() } }", "", SourceType::cjs(), &options);
// `g` has references only outside registered functions, so counts own its
// lifecycle. Once the false branch and then `g` disappear, dropping `g`'s
// body reference wakes the graph and exposes recursive `f`.
test_options_source_type(
"if (false) g(); function g() { f() } function f() { f() }",
"",
SourceType::cjs(),
&options,
);
// A strict block binding and bindings local to a function are not visible
// to later script tags.
test_options_source_type(
"\"use strict\"; { function f() { f() } }",
"\"use strict\";",
SourceType::script(),
&options,
);
test_options_source_type(
"function outer() { function c() { d() } function d() { c() } return 1 }",
"function outer() { return 1 }",
SourceType::script(),
&options,
);
}
#[test]
fn keep_commonjs_references_and_script_global_functions() {
let options = CompressOptions::smallest();
// CommonJS export assignments keep `f` through ordinary resolved
// references; they do not use the Script-root observability rule below.
test_same_options_source_type(
"function f() { f() } module.exports = f;",
SourceType::cjs(),
&options,
);
test_same_options_source_type(
"function f() { f() } exports.f = f;",
SourceType::cjs(),
&options,
);
// Script-root declarations are visible to later script tags. A simple
// sloppy block function is Annex B-hoisted to that observable root binding;
// duplicate/unhoisted safety is covered by the following test.
test_same_options_source_type("function f() { f() }", SourceType::script(), &options);
test_same_options_source_type("{ function f() { f() } }", SourceType::script(), &options);
test_options_source_type(
"function f() {} function outer() { function d1() { console.log(f); d2() } function d2() { d1() } return 1 } f.x = 1;",
"function f() {} function outer() { return 1 } f.x = 1;",
SourceType::script(),
&options,
);
}
#[test]
fn keep_sloppy_duplicate_block_functions() {
let options = CompressOptions::smallest();
let source =
"{ function f() { return 1 } } { function f() { return f } } console.log(typeof f());";
for source_type in [SourceType::script(), SourceType::cjs()] {
test_same_options_source_type(source, source_type, &options);
}
test_same_options_source_type(
"{ function f() { return f } } { function f() { return f } } console.log(typeof f());",
SourceType::ts().with_script(true),
&options,
);
// Strict block functions have no Annex B var alias and remain removable.
test_options_source_type(
"'use strict'; { function f() { f() } }",
"'use strict';",
SourceType::cjs(),
&options,
);
test_options_source_type(
"'use strict'; { function f() { f() } }",
"'use strict';",
SourceType::ts().with_script(true),
&options,
);
}
#[test]
fn keep_sloppy_annex_b_alias_member_write_after_cycle_removed() {
let options = CompressOptions::smallest();
let source = "function outer() { { function f() {} } { function f() {} f.x = 1; function d1() { consume(f); d2() } function d2() { d1() } } console.log(f.x); } outer();";
let expected = "function outer() { { function f() {} } { function f() {} f.x = 1; } console.log(f.x); } outer();";
for source_type in [SourceType::script(), SourceType::cjs()] {
test_options_source_type(source, expected, source_type, &options);
}
}
#[test]
fn keep_script_root_var_in_nested_statement_after_cycle_removed() {
let options = CompressOptions::smallest();
let source = "function outer() { function d1() { return x + d2() } function d2() { return d1() } return 1 } outer(); switch (1) { case 1: var x = 42; }";
let expected = "function outer() { return 1 } switch (1) { case 1: var x = 42; }";
test_options_source_type(source, expected, SourceType::script(), &options);
// CommonJS top-level vars are wrapper-local, so ordinary counts may remove them.
test_options_source_type("{ var x = 42; }", "", SourceType::cjs(), &options);
}
// ---- Direct eval ----
// A dropped direct eval must re-enable the analysis: the initial compute
// skips the whole program while the root scope carries `DirectEval`, so only
// the `eval_dropped` recompute trigger lets a later pass remove the cycle.
#[test]
fn remove_recursive_function_after_eval_dropped() {
test_smallest("if (false) eval('x'); function f() { f() }", "");
let options = CompressOptions::smallest();
test_options_source_type(
"if (false) eval('x'); function f() { f() }",
"",
SourceType::cjs(),
&options,
);
test_options_source_type(
"function outer() { if (false) eval('x'); function f() { f() } return 1 }",
"function outer() { return 1 }",
SourceType::script(),
&options,
);
}
#[test]
fn keep_non_module_recursive_functions_reachable_by_eval() {
let options = CompressOptions::smallest();
test_same_options_source_type("eval('f()'); function f() { f() }", SourceType::cjs(), &options);
test_same_options_source_type(
"function outer() { eval('f()'); function f() { f() } }",
SourceType::script(),
&options,
);
}
// ---- Other binding contexts and options ----
#[test]
fn keep_recursive_cycle_in_for_in_head() {
// No removal site handles for-in/of head declarators, so the head
// survives; its Annex-B initializer must keep referencing a live `p`.
let options = CompressOptions::smallest();
let source_type = SourceType::cjs().with_script(true);
test_same_options_source_type(
"function o() { var p = function() { console.log(x) }; for (var x = p in {}); return 1; } g(o());",
source_type,
&options,
);
}
// For-head bindings need no special pin. References in the RHS participate in
// normal scope ownership, while unreachable heads disappear through the
// ordinary dirty-reference lifecycle.
#[test]
fn for_head_reachability_uses_ordinary_references() {
test_same_smallest("function f() {\n\tf();\n}\nfor (var x of [f]);");
test_smallest(
"function f() { f() } for (var unused in object);",
"for (var unused in object);",
);
test_smallest(
"export function f() { return 1; for (const x of arr) g(x); }",
"export function f() {\n\treturn 1;\n}",
);
test_smallest(
"var f = 1; function d1() { f; d2() } function d2() { d1() } if (false) for (var f of xs) {} export {};",
"export {};",
);
test_smallest("if (false) for (var f of xs) {} f = 1; export {};", "export {};");
}
// References in `using` initializers participate normally in function
// reachability; no separate pin is needed.
#[test]
fn keep_using_declarator_cycle() {
test_same_smallest(
"function o() { using u = p; var p = function() { u() }; return 1 } g(o());",
);
test_same_smallest(
"async function o() { await using u = p; var p = function() { u() }; return 1 } g(o());",
);
test_same_smallest("function f() {\n\tf();\n}\nusing resource = f;");
}
#[test]
fn keep_using_member_write_observed_by_disposal() {
let disposer =
"using resource = { [Symbol.dispose]() { console.log(this.x) } }; resource.x = 1;";
test_same_options(
disposer,
&CompressOptions {
unused: CompressOptionsUnused::Keep,
treeshake: TreeShakeOptions {
property_write_side_effects: false,
..TreeShakeOptions::default()
},
..CompressOptions::smallest()
},
);
test_same_smallest(disposer);
test_same_smallest(
"await using resource = { [Symbol.asyncDispose]() { console.log(this.x) } }; resource.x = 1;",
);
test_smallest(
"{ using resource = { [Symbol.dispose]() { console.log(this.x) } }; resource.x = 1; function d1() { consume(resource); d2() } function d2() { d1() } }",
"{ using resource = { [Symbol.dispose]() { console.log(this.x) } }; resource.x = 1; }",
);
}
#[test]
fn keep_recursive_function_with_unused_keep_option() {
let options =
CompressOptions { unused: CompressOptionsUnused::Keep, ..CompressOptions::smallest() };
test_same_options("function f() { f() }", &options);
test_same_options("const f = () => f()", &options);
// The graph is disabled, but export observability still protects the
// adjacent-declarator single-use substitution path.
test_same_options("export var f = side(), g = f; use(g);", &options);
// Non-ESM sources do not need observability metadata when their graph is
// disabled, but behavior remains identical.
test_same_options_source_type("function f() { f() }", SourceType::cjs(), &options);
test_same_options_source_type(
"function outer() { function f() { f() } }",
SourceType::script(),
&options,
);
}
#[test]
fn remove_unused_import_specifiers() {
let options = CompressOptions::smallest();
@@ -540,13 +540,9 @@ fn test_fold_is_object_and_not_null() {
#[test]
fn test_fold_is_object_and_not_null_minted_then_dropped() {
// Exercises the same-pass mint-then-drop flow through `DropDiff`'s
// capacity guard: `substitute_is_object_and_not_null` mints fresh
// references (indices beyond the pass bitset's capacity) at
// `exit_expression`, then the unreachable statement containing them is
// dropped at `exit_statements` in the same pass. The guard must treat
// the minted refs as live (conservative — callers rebuild scoping), not
// panic.
// Exercises same-pass mint-then-drop through `DropDiff`'s capacity guard.
// The fresh references carry their semantic scope, so post-flush
// reachability observes them without a separate mint log.
test(
"function f(a) { return 1; if (typeof a === 'object' && a !== null) b(a); } f();",
"function f(a) { return 1; }",
+1 -1
View File
@@ -21,7 +21,7 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
3.20 MB | 1.00 MB | 1.01 MB | 322.59 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.13 MB | 2.31 MB | 455.48 kB | 488.28 kB | 5 | antd.js
6.69 MB | 2.12 MB | 2.31 MB | 453.85 kB | 488.28 kB | 5 | antd.js
10.95 MB | 3.33 MB | 3.49 MB | 853.03 kB | 915.50 kB | 4 | typescript.js
@@ -5,9 +5,9 @@ checker.ts:
sys deallocs: 155
sys alloc bytes: 15028748 # 15.03 MB
sys peak growth: 10203896 # 10.20 MB
arena allocs: 152755
arena reallocs: 28253
arena size: 19046536 # 19.05 MB
arena allocs: 152763
arena reallocs: 28276
arena size: 19312968 # 19.31 MB
App.tsx:
file size: 415342 # 415.34 kB
@@ -16,9 +16,9 @@ App.tsx:
sys deallocs: 63
sys alloc bytes: 2136669 # 2.14 MB
sys peak growth: 1620605 # 1.62 MB
arena allocs: 17028
arena allocs: 17029
arena reallocs: 2702
arena size: 2915504 # 2.92 MB
arena size: 2915736 # 2.92 MB
RadixUIAdoptionSection.jsx:
file size: 2520 # 2.52 kB
@@ -27,9 +27,9 @@ RadixUIAdoptionSection.jsx:
sys deallocs: 21
sys alloc bytes: 17368 # 17.37 kB
sys peak growth: 11628 # 11.63 kB
arena allocs: 32
arena allocs: 33
arena reallocs: 6
arena size: 35760 # 35.76 kB
arena size: 35768 # 35.77 kB
pdf.mjs:
file size: 567296 # 567.30 kB
@@ -38,9 +38,9 @@ pdf.mjs:
sys deallocs: 2469
sys alloc bytes: 5348727 # 5.35 MB
sys peak growth: 3707391 # 3.71 MB
arena allocs: 47467
arena reallocs: 7742
arena size: 6356528 # 6.36 MB
arena allocs: 47452
arena reallocs: 7743
arena size: 6375424 # 6.38 MB
antd.js:
file size: 6686316 # 6.69 MB
@@ -49,9 +49,9 @@ antd.js:
sys deallocs: 1044
sys alloc bytes: 29987563 # 29.99 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 372552
arena reallocs: 76745
arena size: 49031840 # 49.03 MB
arena allocs: 371535
arena reallocs: 76376
arena size: 49107608 # 49.11 MB
binder.ts:
file size: 193077 # 193.08 kB
@@ -60,18 +60,18 @@ binder.ts:
sys deallocs: 33
sys alloc bytes: 963614 # 963.61 kB
sys peak growth: 658042 # 658.04 kB
arena allocs: 7076
arena reallocs: 824
arena size: 1157952 # 1.16 MB
arena allocs: 7084
arena reallocs: 840
arena size: 1169808 # 1.17 MB
kitchen-sink.tsx:
file size: 732898 # 732.90 kB
sys allocs: 2309
sys allocs: 1872
sys reallocs: 175
sys deallocs: 2309
sys alloc bytes: 5353468 # 5.35 MB
sys deallocs: 1872
sys alloc bytes: 5346850 # 5.35 MB
sys peak growth: 3738631 # 3.74 MB
arena allocs: 88097
arena reallocs: 15654
arena size: 10190368 # 10.19 MB
arena allocs: 71947
arena reallocs: 12262
arena size: 9560960 # 9.56 MB