mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(minifier): align class heritage removal with assumptions (#24533)
#24349 made unused-class removal conservatively keep every class whose heritage resolves to a class, lexical, or `var` binding. That preserves class-evaluation errors which the minifier assumptions explicitly allow optimizations to ignore, and it leaves `(class extends Base {});` residue in Rolldown chunks after tree-shaking ([rolldown#10274](https://github.com/rolldown/rolldown/issues/10274)). The minifier already documents both `No TDZ Violation` and `No side effects from extending a class`, and `oxc_ecmascript` explicitly ignores invalid-superclass errors so removable derived classes are not broadly deoptimized. Treat resolved identifier heritage like other pure reads while retaining the pre-existing literal-arrow exception, including its paren/sequence unwrap. Effects from evaluating the heritage expression, decorators, and effectful class elements remain observable. The runtime harness already has minifier-only exclusions for unsupported inputs. Six Test262 fixtures covered by these assumptions are skipped only before the minifier variant, with the reason documented beside the list; codegen and transformer still execute, and `runtime.snap` remains unchanged. ## Example Input: ```js export var Base = class {}; export var Keep = class extends Base {}; var REMOVE = class extends Base {}; ``` Before: ```js var Base = class {}, Keep = class extends Base {}; (class extends Base {}); export { Base, Keep }; ``` After: ```js var Base = class {}, Keep = class extends Base {}; export { Base, Keep }; ``` Verified with `cargo test -p oxc_minifier`, `cargo coverage runtime`, Clippy with warnings denied for `oxc_minifier` and `oxc_coverage`, `just minsize`, `just allocs`, formatting, and `just ready`. Minsize and `runtime.snap` are unchanged; the allocation snapshot records fewer allocations for `kitchen-sink.tsx`. Implemented with AI assistance (Claude Code and OpenAI Codex). The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository AI usage policy.
This commit is contained in:
1 parent
48b59f450d
commit
64c224117c
5 files changed
+56
-76
No files matched your search
@@ -10,9 +10,8 @@ use oxc_ecmascript::{
|
||||
ToPrimitive,
|
||||
side_effects::{MayHaveSideEffects, MayHaveSideEffectsContext},
|
||||
};
|
||||
use oxc_semantic::Scoping;
|
||||
use oxc_span::GetSpan;
|
||||
use oxc_syntax::symbol::{SymbolFlags, SymbolId};
|
||||
use oxc_syntax::symbol::SymbolId;
|
||||
|
||||
use super::PeepholeOptimizations;
|
||||
use super::fold_constants::is_cjs_module_exports_hint;
|
||||
@@ -1010,7 +1009,7 @@ impl<'a> PeepholeOptimizations {
|
||||
c: &mut Class<'a>,
|
||||
ctx: &mut TraverseCtx<'a>,
|
||||
) -> Option<ArenaVec<'a, Expression<'a>>> {
|
||||
match Self::classify_class_removability(c, &*ctx, ctx.scoping()) {
|
||||
match Self::classify_class_removability(c, &*ctx) {
|
||||
ClassRemovability::Keep => return None,
|
||||
// Nothing to extract by construction: `RemovesClean` means pure
|
||||
// heritage, no present static values, pure computed keys — so
|
||||
@@ -1077,7 +1076,6 @@ impl<'a> PeepholeOptimizations {
|
||||
pub(crate) fn classify_class_removability(
|
||||
c: &Class<'a>,
|
||||
ctx: &impl MayHaveSideEffectsContext<'a>,
|
||||
scoping: &Scoping,
|
||||
) -> ClassRemovability {
|
||||
// Don't remove classes with decorators - they may have side effects.
|
||||
if !c.decorators.is_empty() {
|
||||
@@ -1092,15 +1090,10 @@ impl<'a> PeepholeOptimizations {
|
||||
let Some(last) = seq.expressions.last() else { break };
|
||||
e = last.get_inner_expression();
|
||||
}
|
||||
match e {
|
||||
// TypeError `class C extends (() => {}) {}`.
|
||||
Expression::ArrowFunctionExpression(_) => return ClassRemovability::Keep,
|
||||
Expression::Identifier(ident)
|
||||
if Self::heritage_may_be_uninitialized(ident, scoping) =>
|
||||
{
|
||||
return ClassRemovability::Keep;
|
||||
}
|
||||
_ => {}
|
||||
// Keep the existing statically-provable TypeError for literal
|
||||
// arrow heritage: `class C extends (() => {}) {}`.
|
||||
if matches!(e, Expression::ArrowFunctionExpression(_)) {
|
||||
return ClassRemovability::Keep;
|
||||
}
|
||||
}
|
||||
let mut extracts = false;
|
||||
@@ -1144,29 +1137,6 @@ impl<'a> PeepholeOptimizations {
|
||||
if extracts { ClassRemovability::Extracts } else { ClassRemovability::RemovesClean }
|
||||
}
|
||||
|
||||
/// A heritage identifier can throw at class-evaluation time regardless
|
||||
/// of expression purity: the class's own name and any lexical binding
|
||||
/// declared later are in their TDZ (ReferenceError — test262
|
||||
/// class/name-binding/in-extends-expression.js), and a
|
||||
/// hoisted-but-unassigned `var` or missing parameter evaluates to
|
||||
/// `undefined` (TypeError: not a constructor). Reference order cannot be
|
||||
/// proven mid-minification (transforms copy and move spans), so any
|
||||
/// heritage resolving to a class, lexical, or `var` binding is
|
||||
/// conservatively unremovable. Hoisted plain function declarations and
|
||||
/// import bindings are initialized before evaluation and stay removable;
|
||||
/// unresolved identifiers keep flowing through the global side-effect
|
||||
/// machinery. The deeper fix — modeling potentially-uninitialized
|
||||
/// identifier reads — belongs in `oxc_ecmascript`'s side-effect layer,
|
||||
/// which currently treats every resolved identifier read as pure.
|
||||
fn heritage_may_be_uninitialized(ident: &IdentifierReference<'_>, scoping: &Scoping) -> bool {
|
||||
const UNINIT_RISK: SymbolFlags = SymbolFlags::Variable.union(SymbolFlags::Class);
|
||||
ident
|
||||
.reference_id
|
||||
.get()
|
||||
.and_then(|reference_id| scoping.get_reference(reference_id).symbol_id())
|
||||
.is_some_and(|symbol_id| scoping.symbol_flags(symbol_id).intersects(UNINIT_RISK))
|
||||
}
|
||||
|
||||
/// Expression kinds the `remove_unused_expression` dispatch above sends
|
||||
/// to a specialized handler, which may REDUCE the expression (leaving
|
||||
/// residue) instead of dropping it whole (`ThisExpression` counts as
|
||||
|
||||
@@ -863,6 +863,15 @@ fn dce_recursive_unused_functions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dce_remove_unused_class_identifier_heritage_under_assumptions() {
|
||||
// ASSUMPTIONS.md excludes TDZ violations and side effects from extending a class.
|
||||
test(
|
||||
"export var Base = class {}; export var Keep = class extends Base {}; var REMOVE = class extends Base {};",
|
||||
"export var Base = class {}; export var Keep = class extends Base {};",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "TODO: extend recursive reachability to mutual declarators and classes"]
|
||||
fn dce_recursive_unused_mutual_declarators_and_classes() {
|
||||
|
||||
@@ -544,42 +544,28 @@ fn keep_recursive_cycle_with_side_effectful_evaluation() {
|
||||
|
||||
// ---- Class heritage classification ----
|
||||
|
||||
#[test]
|
||||
fn remove_unused_class_identifier_heritage_under_assumptions() {
|
||||
// ASSUMPTIONS.md excludes TDZ violations and side effects from extending a class.
|
||||
test_smallest("var A = class {}; var B = class extends A {}; var C = class extends B {};", "");
|
||||
test_smallest("(class extends g() {});", "g();");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_class_cycle_with_wrapped_arrow_heritage() {
|
||||
// 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);",
|
||||
);
|
||||
// The single, fully-unused class is kept for the same reason a literal
|
||||
// arrow heritage is kept: evaluating it is a guaranteed TypeError.
|
||||
// The arrow check sees through a sequence wrapper.
|
||||
test_smallest("class C extends (0, () => {}) {}", "class C extends (() => {}) {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_class_with_tdz_or_undefined_heritage() {
|
||||
// test262 language/statements/class/name-binding/in-extends-expression.js:
|
||||
// the class's own name is in its TDZ while the heritage evaluates, so the
|
||||
// declaration is a guaranteed ReferenceError that must survive.
|
||||
fn keep_referenced_classes_with_identifier_heritage() {
|
||||
// Ordinary references keep these classes live independently of heritage errors.
|
||||
test_same_smallest("class C extends C {}");
|
||||
// The test262 shape: the class lives in a callback whose call is live.
|
||||
// (A NAMED function wrapper additionally hits a pre-existing hole in the
|
||||
// pure-function model — `may_have_side_effects` does not model heritage
|
||||
// TDZ throws, so `f` reads as pure and the call is dropped on `main`
|
||||
// too; that is a separate `oxc_ecmascript` issue, not covered here.)
|
||||
test_same_smallest("g(function() {\n\tclass C extends C {}\n});");
|
||||
// The wrapped variant classifies through the same heritage unwrap.
|
||||
test_smallest("class C extends (0, C) {}", "class C extends C {}");
|
||||
// A forward lexical heritage also evaluates in its TDZ; reference order
|
||||
// cannot be proven mid-minification (transforms copy and move spans), so
|
||||
// any class/lexical/`var` heritage keeps the class.
|
||||
test_same_smallest(
|
||||
"class A extends B {\n\tm() {\n\t\tnew A();\n\t}\n}\nclass B {\n\tm() {\n\t\tnew A();\n\t}\n}",
|
||||
);
|
||||
// `var` heritage: a hoisted-but-unassigned binding is `undefined`, and
|
||||
// `extends undefined` is a TypeError.
|
||||
test_same_smallest("var B = class {};\nclass A extends B {\n\tm() {\n\t\tnew A();\n\t}\n}");
|
||||
}
|
||||
|
||||
@@ -589,12 +575,12 @@ fn keep_class_cycle_with_hoisted_function_heritage() {
|
||||
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.
|
||||
// Classes remain count-managed. A logical fold (`0 || Y` -> `Y`) may change
|
||||
// heritage structure mid-pass, but it must not make the surrounding class
|
||||
// cycle removable. Everything is kept; only the fold itself changes output.
|
||||
#[test]
|
||||
fn keep_class_cycle_with_fold_unstable_heritage() {
|
||||
// Risky identifier (initialized var) inside a foldable wrapper.
|
||||
// Identifier heritage 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}",
|
||||
|
||||
@@ -94,6 +94,17 @@ static SKIP_TEST_CASES: &[&str] = &[
|
||||
"language/expressions/prefix-decrement/operator-prefix-decrement-x-calls-putvalue-lhs-newvalue",
|
||||
];
|
||||
|
||||
static SKIP_MINIFIER_TEST_CASES: &[&str] = &[
|
||||
// The minifier assumes no TDZ violations and no side effects from extending a class.
|
||||
// See `oxc_minifier/docs/ASSUMPTIONS.md`.
|
||||
"language/statements/class/name-binding/in-extends-expression-assigned.js",
|
||||
"language/statements/class/name-binding/in-extends-expression-grouped.js",
|
||||
"language/statements/class/definition/constructable-but-no-prototype.js",
|
||||
"language/statements/class/definition/prototype-getter.js",
|
||||
"language/statements/class/definition/prototype-setter.js",
|
||||
"language/statements/class/subclass/superclass-arrow-function.js",
|
||||
];
|
||||
|
||||
static SKIP_ESID: &[&str] = &["sec-privatefieldget", "sec-privatefieldset"];
|
||||
|
||||
/// The JSON payload sent to the Node.js runtime server for each execution.
|
||||
@@ -266,6 +277,10 @@ fn run_variants(
|
||||
return TestResult::Passed;
|
||||
}
|
||||
|
||||
if SKIP_MINIFIER_TEST_CASES.contains(&test262_path) {
|
||||
return TestResult::Passed;
|
||||
}
|
||||
|
||||
// None of the minifier conform to "fn-name-cover.js"
|
||||
// `let xCover = (0, function() {});` xCover.name is ''
|
||||
// `let xCover = function() {};` xCover.name is 'xCover'
|
||||
|
||||
@@ -3,7 +3,7 @@ checker.ts:
|
||||
sys allocs: 152
|
||||
sys reallocs: 5
|
||||
sys deallocs: 152
|
||||
sys alloc bytes: 15028748 # 15.03 MB
|
||||
sys alloc bytes: 15030872 # 15.03 MB
|
||||
sys peak growth: 10203896 # 10.20 MB
|
||||
arena allocs: 152864
|
||||
arena reallocs: 28386
|
||||
@@ -36,18 +36,18 @@ pdf.mjs:
|
||||
sys allocs: 2467
|
||||
sys reallocs: 205
|
||||
sys deallocs: 2467
|
||||
sys alloc bytes: 5348727 # 5.35 MB
|
||||
sys alloc bytes: 5348219 # 5.35 MB
|
||||
sys peak growth: 3707391 # 3.71 MB
|
||||
arena allocs: 47424
|
||||
arena reallocs: 7715
|
||||
arena size: 6374064 # 6.37 MB
|
||||
arena size: 6373936 # 6.37 MB
|
||||
|
||||
antd.js:
|
||||
file size: 6686316 # 6.69 MB
|
||||
sys allocs: 1038
|
||||
sys reallocs: 56
|
||||
sys deallocs: 1038
|
||||
sys alloc bytes: 29987563 # 29.99 MB
|
||||
sys alloc bytes: 29975903 # 29.98 MB
|
||||
sys peak growth: 19934725 # 19.93 MB
|
||||
arena allocs: 371435
|
||||
arena reallocs: 76276
|
||||
@@ -62,16 +62,16 @@ binder.ts:
|
||||
sys peak growth: 658042 # 658.04 kB
|
||||
arena allocs: 7079
|
||||
arena reallocs: 841
|
||||
arena size: 1169808 # 1.17 MB
|
||||
arena size: 1169856 # 1.17 MB
|
||||
|
||||
kitchen-sink.tsx:
|
||||
file size: 732898 # 732.90 kB
|
||||
sys allocs: 1867
|
||||
sys reallocs: 175
|
||||
sys deallocs: 1867
|
||||
sys alloc bytes: 5346850 # 5.35 MB
|
||||
sys peak growth: 3738631 # 3.74 MB
|
||||
arena allocs: 71932
|
||||
sys allocs: 1858
|
||||
sys reallocs: 174
|
||||
sys deallocs: 1858
|
||||
sys alloc bytes: 5337950 # 5.34 MB
|
||||
sys peak growth: 3737991 # 3.74 MB
|
||||
arena allocs: 71832
|
||||
arena reallocs: 12252
|
||||
arena size: 9560960 # 9.56 MB
|
||||
arena size: 9559000 # 9.56 MB
|
||||
|
||||
Reference in new issue
Block a user