From acfb2dea2e9c13dd7f51a609d80b508169e651df Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 14:09:20 +0800 Subject: [PATCH 1/4] fix(optimizer): keep classes whose definition runs code out of the pure wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private static field, a static block that is not `this.x = …`, an impure superclass or computed key, a static accessor initializer and class decorators all run when the class is defined, so wrapping the class in a pure IIFE let bundlers drop them. Member reads, logical operators and a few globals now count as pure, like the Angular CLI's isPure. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/optimizer/adjust_static_members.rs | 104 +++++++++++++----- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs index c925267ef..a48f577e3 100644 --- a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs +++ b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs @@ -108,7 +108,8 @@ impl AdjustStaticMembersTransformer { let bindings = top_level_bindings(program); let mut edits = Vec::new(); for group in self.find_class_groups(program, &bindings, source) { - if group.side_effects { + // decorators run when the class is defined, and `@dec export let X = …` would not parse + if group.side_effects || !group.class.decorators.is_empty() { continue; } // Like the Angular CLI's adjust-static-class-members: downleveled Angular statics after the class, or @@ -272,7 +273,7 @@ impl AdjustStaticMembersTransformer { /// read, as Babel's `scope.isPure` sees one with a binding fn top_level_bindings<'a>(program: &'a Program<'a>) -> FxHashSet<&'a str> { let mut names = FxHashSet::default(); - let mut add_pattern = |p: &'a BindingPattern<'a>, names: &mut FxHashSet<&'a str>| { + let add_pattern = |p: &'a BindingPattern<'a>, names: &mut FxHashSet<&'a str>| { if let BindingPattern::BindingIdentifier(id) = p { names.insert(id.name.as_str()); } @@ -310,24 +311,33 @@ fn top_level_bindings<'a>(program: &'a Program<'a>) -> FxHashSet<&'a str> { names } -/// The Angular CLI's `analyzeClassStaticProperties`: every static property, and every static block assigning -/// `this. = …`, holds an Angular definition or a value without side effects. Static blocks of more than one -/// statement, computed keys, and assignments to anything but `this` are not wrapped. +/// The Angular CLI's `analyzeClassStaticProperties`: every static property, and every nonempty static block, is +/// `this. = …` holding an Angular definition or a value without side effects. Private and computed static +/// fields and other static blocks are not wrapped. Unlike the CLI, an impure superclass or computed key also keeps +/// the class out: both run when the class is defined, and the pure wrapper would let them be dropped. fn class_static_members_wrappable( class: &Class<'_>, bindings: &FxHashSet<&str>, source: &str, ) -> bool { + if class.heritage.as_ref().is_some_and(|h| { + !has_pure_annotation(h.expression.span(), source) && !is_pure(&h.expression, bindings) + }) { + return false; + } let mut should_wrap = false; for element in &class.body.body { + if element.computed() + && element + .property_key() + .and_then(PropertyKey::as_expression) + .is_none_or(|key| !is_pure(key, bindings)) + { + return false; + } match element { ClassElement::PropertyDefinition(prop) if prop.r#static => { - let PropertyKey::StaticIdentifier(key) = &prop.key else { - if matches!(prop.key, PropertyKey::PrivateIdentifier(_)) { - continue; - } - return false; - }; + let PropertyKey::StaticIdentifier(key) = &prop.key else { return false }; match &prop.value { None => should_wrap = true, Some(value) @@ -339,22 +349,16 @@ fn class_static_members_wrappable( } } ClassElement::StaticBlock(block) => { - if block.body.len() > 1 { + let stmt = match block.body.as_slice() { + [] => continue, + [Statement::ExpressionStatement(stmt)] => stmt, + _ => return false, + }; + let Expression::AssignmentExpression(assign) = &stmt.expression else { return false; - } - let Some(Statement::ExpressionStatement(stmt)) = block.body.first() else { - continue; }; - let Expression::AssignmentExpression(assign) = &stmt.expression else { continue }; let AssignmentTarget::StaticMemberExpression(member) = &assign.left else { - if matches!( - assign.left, - AssignmentTarget::ComputedMemberExpression(_) - | AssignmentTarget::PrivateFieldExpression(_) - ) { - return false; - } - continue; + return false; }; if !matches!(member.object, Expression::ThisExpression(_)) { return false; @@ -366,6 +370,12 @@ fn class_static_members_wrappable( return false; } } + // `static accessor x = …` runs its initializer when the class is defined, and the CLI does not check it + ClassElement::AccessorProperty(accessor) + if accessor.r#static && accessor.value.is_some() => + { + return false; + } _ => {} } } @@ -395,8 +405,11 @@ fn has_pure_annotation(span: Span, source: &str) -> bool { || comment.contains("@pureOrBreakMyCode") } +/// Globals that can be read without side effects +const PURE_GLOBALS: &[&str] = &["undefined", "NaN", "Infinity", "Symbol"]; + /// Babel's `scope.isPure`, for what static members hold: literals, functions, classes, bound identifiers, and -/// arrays, objects, templates and operators made of those +/// arrays, objects, templates, member reads and operators made of those fn is_pure(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { match expr { Expression::BooleanLiteral(_) @@ -407,7 +420,17 @@ fn is_pure(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { | Expression::StringLiteral(_) | Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) => true, - Expression::Identifier(id) => bindings.contains(id.name.as_str()), + Expression::Identifier(id) => { + bindings.contains(id.name.as_str()) || PURE_GLOBALS.contains(&id.name.as_str()) + } + // like the CLI's isPure, reading a member counts as pure + Expression::StaticMemberExpression(m) => is_pure(&m.object, bindings), + Expression::ComputedMemberExpression(m) => { + is_pure(&m.object, bindings) && is_pure(&m.expression, bindings) + } + Expression::LogicalExpression(l) => { + is_pure(&l.left, bindings) && is_pure(&l.right, bindings) + } Expression::TemplateLiteral(t) => t.expressions.iter().all(|e| is_pure(e, bindings)), Expression::ArrayExpression(a) => a.elements.iter().all(|el| match el { oxc_ast::ast::ArrayExpressionElement::SpreadElement(_) => false, @@ -591,6 +614,35 @@ export { ButtonComponent };"#; assert!(result.ends_with("A.ɵcmp = defineComponent({});\n return A;\n})();"), "{result}"); } + #[test] + fn class_definition_side_effects_stay() { + // each of these runs code when the class is defined, so a pure wrapper could drop it + for code in [ + "class A { static x = 0; static #r = register(); }", + "class A { static x = 0; static { register(A); } }", + "let y; class A { static x = 0; static { y = register(); } }", + "class A { static x = 0; static { this[key()] = 1; } }", + "class A extends registerBase() { static x = 0; }", + "class A { static x = 0; [register()]() {} }", + "class A { static x = 0; [register()] = 1; }", + "class A { static x = 0; static accessor y = register(); }", + ] { + assert_eq!(transform(code), code, "{code}"); + } + // decorated classes stay too: `@dec export let A = …` would not parse + let code = "@dec export class A {\n static ɵfac = f;\n}"; + assert_eq!(transform(code), code); + // pure superclasses, computed keys and values still wrap + for code in [ + "class B {}\nclass A extends B { static x = 0; ['k']() {} }", + "import * as i0 from 'x';\nclass A extends i0.Base { static x = i0.Foo; *[Symbol.iterator]() {} }", + "class A extends /* @__PURE__ */ mixin() { static x = 0; }", + "let a, b;\nclass A { static x = a || b; static y = undefined; }", + ] { + assert!(transform(code).contains("/* @__PURE__ */"), "{code}"); + } + } + #[test] fn plain_classes_stay() { let code = "class A {\n method() { return 1; }\n}"; From a88d0c4e45621861d87fb5b4b0110011c903130e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 14:18:54 +0800 Subject: [PATCH 2/4] fix(optimizer): accept member reads only in superclasses and computed keys A member read can run a getter, so a static value such as `registry.value` keeps the class unwrapped. Superclasses and computed keys such as `i0.Base` and `Symbol.iterator` still count as pure: the Angular CLI does not check those at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/optimizer/adjust_static_members.rs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs index a48f577e3..16283800e 100644 --- a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs +++ b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs @@ -321,7 +321,8 @@ fn class_static_members_wrappable( source: &str, ) -> bool { if class.heritage.as_ref().is_some_and(|h| { - !has_pure_annotation(h.expression.span(), source) && !is_pure(&h.expression, bindings) + !has_pure_annotation(h.expression.span(), source) + && !is_pure_reference(&h.expression, bindings) }) { return false; } @@ -331,7 +332,7 @@ fn class_static_members_wrappable( && element .property_key() .and_then(PropertyKey::as_expression) - .is_none_or(|key| !is_pure(key, bindings)) + .is_none_or(|key| !is_pure_reference(key, bindings)) { return false; } @@ -405,11 +406,21 @@ fn has_pure_annotation(span: Span, source: &str) -> bool { || comment.contains("@pureOrBreakMyCode") } +/// A superclass or computed key the CLI would not check at all: a pure value, or a member read on one such as +/// `i0.Base` or `Symbol.iterator`. A member read could run a getter, so static values do not accept it. +fn is_pure_reference(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { + match expr { + Expression::StaticMemberExpression(m) => is_pure_reference(&m.object, bindings), + Expression::ParenthesizedExpression(p) => is_pure_reference(&p.expression, bindings), + _ => is_pure(expr, bindings), + } +} + /// Globals that can be read without side effects const PURE_GLOBALS: &[&str] = &["undefined", "NaN", "Infinity", "Symbol"]; /// Babel's `scope.isPure`, for what static members hold: literals, functions, classes, bound identifiers, and -/// arrays, objects, templates, member reads and operators made of those +/// arrays, objects, templates and operators made of those fn is_pure(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { match expr { Expression::BooleanLiteral(_) @@ -423,11 +434,6 @@ fn is_pure(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { Expression::Identifier(id) => { bindings.contains(id.name.as_str()) || PURE_GLOBALS.contains(&id.name.as_str()) } - // like the CLI's isPure, reading a member counts as pure - Expression::StaticMemberExpression(m) => is_pure(&m.object, bindings), - Expression::ComputedMemberExpression(m) => { - is_pure(&m.object, bindings) && is_pure(&m.expression, bindings) - } Expression::LogicalExpression(l) => { is_pure(&l.left, bindings) && is_pure(&l.right, bindings) } @@ -626,6 +632,8 @@ export { ButtonComponent };"#; "class A { static x = 0; [register()]() {} }", "class A { static x = 0; [register()] = 1; }", "class A { static x = 0; static accessor y = register(); }", + // a member read could run a getter + "import { registry } from 'x';\nclass A { static x = registry.value; }", ] { assert_eq!(transform(code), code, "{code}"); } @@ -635,7 +643,7 @@ export { ButtonComponent };"#; // pure superclasses, computed keys and values still wrap for code in [ "class B {}\nclass A extends B { static x = 0; ['k']() {} }", - "import * as i0 from 'x';\nclass A extends i0.Base { static x = i0.Foo; *[Symbol.iterator]() {} }", + "import * as i0 from 'x';\nclass A extends i0.Base { static x = 0; *[Symbol.iterator]() {} }", "class A extends /* @__PURE__ */ mixin() { static x = 0; }", "let a, b;\nclass A { static x = a || b; static y = undefined; }", ] { From 269d4a47a13ec2f85c9e26537752da0128bb34de Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 14:29:06 +0800 Subject: [PATCH 3/4] fix(optimizer): check the class body when Angular statics follow the class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class followed by `A.ɵfac = f` skipped the class body checks, so an impure superclass, computed key, static field or static block was still wrapped. #604 made `static x = register()` wrap this way; the others were already wrapped before it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/optimizer/adjust_static_members.rs | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs index 16283800e..eba931071 100644 --- a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs +++ b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs @@ -112,11 +112,13 @@ impl AdjustStaticMembersTransformer { if group.side_effects || !group.class.decorators.is_empty() { continue; } + let Some(members_wrap) = class_static_members_wrappable(group.class, &bindings, source) + else { + continue; + }; // Like the Angular CLI's adjust-static-class-members: downleveled Angular statics after the class, or // static members inside it that are all safe to wrap - let wrap = !group.static_members.is_empty() - || class_static_members_wrappable(group.class, &bindings, source); - if wrap { + if !group.static_members.is_empty() || members_wrap { edits.extend(self.wrap_class_group(&group, source)); } } @@ -311,7 +313,8 @@ fn top_level_bindings<'a>(program: &'a Program<'a>) -> FxHashSet<&'a str> { names } -/// The Angular CLI's `analyzeClassStaticProperties`: every static property, and every nonempty static block, is +/// The Angular CLI's `analyzeClassStaticProperties`, run for every class: `None` when defining the class can have +/// side effects, else whether its static members alone make it worth wrapping. Every static property, and every nonempty static block, is /// `this. = …` holding an Angular definition or a value without side effects. Private and computed static /// fields and other static blocks are not wrapped. Unlike the CLI, an impure superclass or computed key also keeps /// the class out: both run when the class is defined, and the pure wrapper would let them be dropped. @@ -319,12 +322,12 @@ fn class_static_members_wrappable( class: &Class<'_>, bindings: &FxHashSet<&str>, source: &str, -) -> bool { +) -> Option { if class.heritage.as_ref().is_some_and(|h| { !has_pure_annotation(h.expression.span(), source) && !is_pure_reference(&h.expression, bindings) }) { - return false; + return None; } let mut should_wrap = false; for element in &class.body.body { @@ -334,11 +337,11 @@ fn class_static_members_wrappable( .and_then(PropertyKey::as_expression) .is_none_or(|key| !is_pure_reference(key, bindings)) { - return false; + return None; } match element { ClassElement::PropertyDefinition(prop) if prop.r#static => { - let PropertyKey::StaticIdentifier(key) = &prop.key else { return false }; + let PropertyKey::StaticIdentifier(key) = &prop.key else { return None }; match &prop.value { None => should_wrap = true, Some(value) @@ -346,41 +349,41 @@ fn class_static_members_wrappable( { should_wrap = true } - Some(_) => return false, + Some(_) => return None, } } ClassElement::StaticBlock(block) => { let stmt = match block.body.as_slice() { [] => continue, [Statement::ExpressionStatement(stmt)] => stmt, - _ => return false, + _ => return None, }; let Expression::AssignmentExpression(assign) = &stmt.expression else { - return false; + return None; }; let AssignmentTarget::StaticMemberExpression(member) = &assign.left else { - return false; + return None; }; if !matches!(member.object, Expression::ThisExpression(_)) { - return false; + return None; } if can_wrap_property(member.property.name.as_str(), &assign.right, bindings, source) { should_wrap = true; } else { - return false; + return None; } } // `static accessor x = …` runs its initializer when the class is defined, and the CLI does not check it ClassElement::AccessorProperty(accessor) if accessor.r#static && accessor.value.is_some() => { - return false; + return None; } _ => {} } } - should_wrap + Some(should_wrap) } /// The Angular CLI's `canWrapProperty`: an Angular definition, a value marked pure, or a pure expression @@ -632,6 +635,10 @@ export { ButtonComponent };"#; "class A { static x = 0; [register()]() {} }", "class A { static x = 0; [register()] = 1; }", "class A { static x = 0; static accessor y = register(); }", + // the same with a downleveled Angular static after the class + "class A extends registerBase() {}\nA.ɵfac = f;", + "class A {\n static x = register();\n}\nA.ɵfac = f;", + "class A {\n static { register(A); }\n}\nA.ɵfac = f;", // a member read could run a getter "import { registry } from 'x';\nclass A { static x = registry.value; }", ] { From 87810b3ece21809750837095d14f46461035b2b9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 14:55:54 +0800 Subject: [PATCH 4/4] fix(optimizer): narrow pure superclasses and keys, and keep decorated members out A superclass must now be a declared name, and a computed key a literal, a declared name or `Symbol.`: reading `ns.Base` can run a getter, and turning an object key into a string can run its `toString`. Decorators on class members run when the class is defined, so those classes stay too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/optimizer/adjust_static_members.rs | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs index eba931071..6ae47fb69 100644 --- a/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs +++ b/crates/oxc_angular_compiler/src/optimizer/adjust_static_members.rs @@ -325,7 +325,7 @@ fn class_static_members_wrappable( ) -> Option { if class.heritage.as_ref().is_some_and(|h| { !has_pure_annotation(h.expression.span(), source) - && !is_pure_reference(&h.expression, bindings) + && !is_pure_superclass(&h.expression, bindings) }) { return None; } @@ -335,10 +335,20 @@ fn class_static_members_wrappable( && element .property_key() .and_then(PropertyKey::as_expression) - .is_none_or(|key| !is_pure_reference(key, bindings)) + .is_none_or(|key| !is_pure_key(key, bindings)) { return None; } + // decorators on members run when the class is defined + let decorated = match element { + ClassElement::MethodDefinition(m) => !m.decorators.is_empty(), + ClassElement::PropertyDefinition(p) => !p.decorators.is_empty(), + ClassElement::AccessorProperty(a) => !a.decorators.is_empty(), + _ => false, + }; + if decorated { + return None; + } match element { ClassElement::PropertyDefinition(prop) if prop.r#static => { let PropertyKey::StaticIdentifier(key) = &prop.key else { return None }; @@ -409,13 +419,32 @@ fn has_pure_annotation(span: Span, source: &str) -> bool { || comment.contains("@pureOrBreakMyCode") } -/// A superclass or computed key the CLI would not check at all: a pure value, or a member read on one such as -/// `i0.Base` or `Symbol.iterator`. A member read could run a getter, so static values do not accept it. -fn is_pure_reference(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { +/// A superclass the CLI would not check at all: a declared name, `null` or a pure class expression. Reading a +/// member could run a getter, so `ns.Base` does not count. +fn is_pure_superclass(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { match expr { - Expression::StaticMemberExpression(m) => is_pure_reference(&m.object, bindings), - Expression::ParenthesizedExpression(p) => is_pure_reference(&p.expression, bindings), - _ => is_pure(expr, bindings), + Expression::Identifier(id) => bindings.contains(id.name.as_str()), + Expression::NullLiteral(_) | Expression::ClassExpression(_) => is_pure(expr, bindings), + Expression::ParenthesizedExpression(p) => is_pure_superclass(&p.expression, bindings), + _ => false, + } +} + +/// A computed key the CLI would not check at all. The key is turned into a property key, which can call +/// `toString` on an object, so only literals, declared names (such as a `const` holding a `Symbol()`) and +/// well-known symbols such as `Symbol.iterator` count. +fn is_pure_key(expr: &Expression<'_>, bindings: &FxHashSet<&str>) -> bool { + match expr { + Expression::StringLiteral(_) + | Expression::NumericLiteral(_) + | Expression::BigIntLiteral(_) => true, + Expression::Identifier(id) => bindings.contains(id.name.as_str()), + Expression::TemplateLiteral(t) => t.expressions.is_empty(), + Expression::StaticMemberExpression(m) => { + matches!(&m.object, Expression::Identifier(id) if id.name == "Symbol" && !bindings.contains("Symbol")) + } + Expression::ParenthesizedExpression(p) => is_pure_key(&p.expression, bindings), + _ => false, } } @@ -639,6 +668,10 @@ export { ButtonComponent };"#; "class A extends registerBase() {}\nA.ɵfac = f;", "class A {\n static x = register();\n}\nA.ɵfac = f;", "class A {\n static { register(A); }\n}\nA.ɵfac = f;", + // a member read could run a getter, a computed key could call `toString`, a member decorator runs + "import * as ns from 'x';\nclass A extends ns.Base { static x = 0; }", + "class A { static x = 0; [{ toString() { register(); return 'm'; } }]() {} }", + "class A { @register method() {} static x = 0; }", // a member read could run a getter "import { registry } from 'x';\nclass A { static x = registry.value; }", ] { @@ -650,7 +683,7 @@ export { ButtonComponent };"#; // pure superclasses, computed keys and values still wrap for code in [ "class B {}\nclass A extends B { static x = 0; ['k']() {} }", - "import * as i0 from 'x';\nclass A extends i0.Base { static x = 0; *[Symbol.iterator]() {} }", + "import { Base } from 'x';\nconst K = Symbol();\nclass A extends Base { static x = 0; *[Symbol.iterator]() {} [`k`]() {} [K]; }", "class A extends /* @__PURE__ */ mixin() { static x = 0; }", "let a, b;\nclass A { static x = a || b; static y = undefined; }", ] {