mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(minifier): refresh direct eval flags after DCE (#21787)
Stacked on #21645. ## Summary Fixes stale `DirectEval` scope flags after peephole passes remove a dead direct eval. ## Problem Semantic analysis marks scopes that contain direct eval. The minifier uses that flag conservatively: if a scope may contain direct eval, declarations in that scope cannot always be removed because eval may reference them dynamically. However, peephole compression can remove the direct eval itself: ```js function f(){if(false)eval('x');var x}f() ``` Before this fix, the AST no longer contained the `eval`, but the semantic scope still kept the stale `DirectEval` flag. That stale flag made declaration removal too conservative during the same minifier run. A fresh reparse did not have the stale flag, so a second minifier run could produce smaller output, which exposed the `monitor-oxc` idempotency failure. ## Fix - After a changed peephole pass, collect direct eval scope IDs from the live AST. - Refresh `DirectEval` flags in semantic scoping from that live set. - Keep `DirectEval` protection for scopes that still contain a live direct eval. - Remove stale `DirectEval` protection only for scopes whose direct eval was eliminated by earlier compression. ## Test Adds a minimal regression in `remove_unused_declaration.rs` using the existing `test_options` helper: ```js function f(){if(false)eval('x');var x}f() ``` The test verifies the dead eval no longer keeps `var x` alive in the same minifier run. ## Testing - `cargo test -p oxc_minifier remove_unused_declaration_after_dead_direct_eval`
This commit is contained in:
1 parent
24946bea55
commit
7a810c0604
2 files changed
+116
-16
No files matched your search
@@ -17,9 +17,12 @@ mod remove_unused_private_members;
|
||||
mod replace_known_methods;
|
||||
mod substitute_alternate_syntax;
|
||||
|
||||
use oxc_ast_visit::Visit;
|
||||
use oxc_semantic::ReferenceId;
|
||||
use oxc_syntax::symbol::SymbolId;
|
||||
use oxc_ast_visit::{Visit, walk::walk_call_expression};
|
||||
use oxc_semantic::{ReferenceId, Scoping};
|
||||
use oxc_syntax::{
|
||||
scope::{ScopeFlags, ScopeId},
|
||||
symbol::SymbolId,
|
||||
};
|
||||
use rustc_hash::FxHashSet;
|
||||
|
||||
use oxc_allocator::Vec;
|
||||
@@ -120,6 +123,39 @@ impl<'a> PeepholeOptimizations {
|
||||
ctx.scoping().symbol_is_mutated(symbol_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh `ScopeFlags::DirectEval` from live direct-eval call sites.
|
||||
///
|
||||
/// `direct_eval_scopes` lists scopes that still contain a direct `eval(...)` call.
|
||||
/// Clears `DirectEval` from every scope, then re-propagates from each scope in the
|
||||
/// set up to the root.
|
||||
///
|
||||
/// Skipping this leaves `DirectEval` set on scopes whose only eval call was just
|
||||
/// DCE'd, which keeps unused-declaration removal conservative until reparse.
|
||||
fn refresh_direct_eval_flags(scoping: &mut Scoping, direct_eval_scopes: &FxHashSet<ScopeId>) {
|
||||
// Semantic propagates `DirectEval` to the root, so an empty live set plus a
|
||||
// clean root means no scope has the flag — nothing to clear or set.
|
||||
if direct_eval_scopes.is_empty() && !scoping.root_scope_flags().contains_direct_eval() {
|
||||
return;
|
||||
}
|
||||
|
||||
for index in 0..scoping.scopes_len() {
|
||||
scoping.scope_flags_mut(ScopeId::from_usize(index)).remove(ScopeFlags::DirectEval);
|
||||
}
|
||||
|
||||
for &scope_id in direct_eval_scopes {
|
||||
let mut ancestor = Some(scope_id);
|
||||
while let Some(scope_id) = ancestor {
|
||||
let flags = scoping.scope_flags_mut(scope_id);
|
||||
// An earlier iteration already flagged this chain; stop walking up.
|
||||
if flags.contains_direct_eval() {
|
||||
break;
|
||||
}
|
||||
flags.insert(ScopeFlags::DirectEval);
|
||||
ancestor = scoping.scope_parent_id(scope_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Traverse<'a> for PeepholeOptimizations {
|
||||
@@ -131,16 +167,20 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
|
||||
|
||||
fn exit_program(&mut self, program: &mut Program<'a>, ctx: &mut TraverseCtx<'a>) {
|
||||
if ctx.state.changed {
|
||||
// Remove stale references by collecting the set of live references from
|
||||
// the AST and retaining only those in each symbol's reference list.
|
||||
// This is done as a batch rather than deleting references one at a time,
|
||||
// because individual deletion (`delete_resolved_reference`) uses a linear
|
||||
// scan (`.position()`) per call, so removing many references to the same
|
||||
// symbol is O(n²) (happens in bundler output with thousands of unused
|
||||
// `var import_X = __toESM(require_Y())` declarations).
|
||||
let mut counter = ReferencesCounter::default();
|
||||
counter.visit_program(program);
|
||||
ctx.scoping_mut().retain_resolved_references(&counter.refs);
|
||||
// Walk the live AST to collect data the peephole pass left stale:
|
||||
// - Live `IdentifierReference` IDs, so dead references can be batch-pruned
|
||||
// from each symbol's reference list (individual deletion via
|
||||
// `delete_resolved_reference` is O(n) per call, O(n²) over many removals,
|
||||
// which shows up in bundler output with thousands of unused
|
||||
// `var import_X = __toESM(require_Y())` declarations).
|
||||
// - Scopes that still contain a direct `eval()` call, needed by
|
||||
// `refresh_direct_eval_flags`.
|
||||
let mut collector = LiveUsageCollector::new(ctx.scoping());
|
||||
collector.visit_program(program);
|
||||
let LiveUsageCollector { refs, direct_eval_scopes, .. } = collector;
|
||||
let scoping = ctx.scoping_mut();
|
||||
scoping.retain_resolved_references(&refs);
|
||||
Self::refresh_direct_eval_flags(scoping, &direct_eval_scopes);
|
||||
}
|
||||
// Only check class_symbols_stack in full optimization mode (not DCE mode)
|
||||
debug_assert!(ctx.state.dce || ctx.state.class_symbols_stack.is_exhausted());
|
||||
@@ -523,12 +563,31 @@ impl<'a> Traverse<'a> for PeepholeOptimizations {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ReferencesCounter {
|
||||
struct LiveUsageCollector<'s> {
|
||||
scoping: &'s Scoping,
|
||||
refs: FxHashSet<ReferenceId>,
|
||||
direct_eval_scopes: FxHashSet<ScopeId>,
|
||||
}
|
||||
|
||||
impl<'a> Visit<'a> for ReferencesCounter {
|
||||
impl<'s> LiveUsageCollector<'s> {
|
||||
fn new(scoping: &'s Scoping) -> Self {
|
||||
Self { scoping, refs: FxHashSet::default(), direct_eval_scopes: FxHashSet::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Visit<'a> for LiveUsageCollector<'_> {
|
||||
fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
|
||||
if !it.optional
|
||||
&& let Some(ident) = it.callee.get_identifier_reference()
|
||||
&& ident.name == "eval"
|
||||
{
|
||||
let scope_id = self.scoping.get_reference(ident.reference_id()).scope_id();
|
||||
self.direct_eval_scopes.insert(scope_id);
|
||||
}
|
||||
// Recurse — `eval` may be nested in another call's arguments, e.g. `foo(eval('x'))`.
|
||||
walk_call_expression(self, it);
|
||||
}
|
||||
|
||||
fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) {
|
||||
let reference_id = it.reference_id();
|
||||
self.refs.insert(reference_id);
|
||||
|
||||
@@ -65,6 +65,47 @@ fn remove_unused_function_declaration() {
|
||||
test_same_options("function foo() { bar } eval('foo()')", &options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unused_declaration_after_dead_direct_eval() {
|
||||
let options = CompressOptions::smallest();
|
||||
test_options("function f(){if(false)eval('x');var x}f()", "", &options);
|
||||
// Live eval still keeps `var x` alive after the refresh.
|
||||
test_same_options("function f(){eval('x');var x}f()", &options);
|
||||
// Parenthesized eval is still direct eval; the wrapped form must keep `var x` alive.
|
||||
test_options(
|
||||
"function f(){if(false)y;(eval)('x');var x}f()",
|
||||
"function f(){(eval)('x');var x}f()",
|
||||
&options,
|
||||
);
|
||||
// Eval nested inside another call's arguments still keeps `var x` alive.
|
||||
// The dead `if(false)y` triggers a peephole change so the refresh actually runs.
|
||||
test_options(
|
||||
"function f(){if(false)y;foo(eval('x'));var x}f()",
|
||||
"function f(){foo(eval('x'));var x}f()",
|
||||
&options,
|
||||
);
|
||||
// Eval in a nested scope: clearing must propagate up through both nested and
|
||||
// outer chains, then re-set only what's still live (here, nothing).
|
||||
test_options(
|
||||
"function outer(){function inner(){if(false)eval('x')}inner();var x}outer()",
|
||||
"",
|
||||
&options,
|
||||
);
|
||||
// Live eval at the program root keeps an otherwise-unused `var x` alive
|
||||
// (the root flag is the global witness checked by `can_remove_unused_declarators`).
|
||||
test_same_options("eval('x');var x", &options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unused_declaration_with_optional_eval() {
|
||||
let options = CompressOptions::smallest();
|
||||
test_options("function f(){if(false)eval?.('x');var x}f()", "", &options);
|
||||
// Live optional eval is indirect — it doesn't set `DirectEval`, so `var x` is
|
||||
// removable even though the call itself stays as a side-effectful expression.
|
||||
// Contrast with the live-direct-eval root case above, where `var x` is kept.
|
||||
test_options("eval?.('x');var x", "eval?.('x');", &options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unused_class_declaration() {
|
||||
let options = CompressOptions::smallest();
|
||||
|
||||
Reference in new issue
Block a user