Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions crates/oxc_angular_compiler/src/component/decorator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ pub fn extract_component_metadata<'a>(
metadata.encapsulation = extract_encapsulation(&prop.value);
}
"changeDetection" => {
metadata.change_detection = extract_change_detection(&prop.value);
metadata.change_detection = Some(extract_change_detection(&prop.value));
}
"host" => {
metadata.host = extract_host_metadata(allocator, &prop.value, consts);
Expand Down Expand Up @@ -469,13 +469,18 @@ fn extract_change_detection(expr: &Expression<'_>) -> ChangeDetectionStrategy {
match expr {
Expression::StaticMemberExpression(member) => match member.property.name.as_str() {
"OnPush" => ChangeDetectionStrategy::OnPush,
"Eager" => ChangeDetectionStrategy::Eager,
// `Default` (value 1) is the pre-v22 spelling of `Eager`. Keep it
// distinct so partial emit can preserve the author's exact member
// for older Angular targets that lack `Eager`.
"Default" => ChangeDetectionStrategy::Default,
_ => ChangeDetectionStrategy::default(),
},
Expression::NumericLiteral(num) => {
// Angular's numeric values: Default = 0, OnPush = 1
// Angular v22 numeric values: OnPush = 0, Eager = 1 (Default = 1).
match num.value as i32 {
1 => ChangeDetectionStrategy::OnPush,
0 => ChangeDetectionStrategy::OnPush,
1 => ChangeDetectionStrategy::Eager,
_ => ChangeDetectionStrategy::default(),
}
}
Expand Down Expand Up @@ -1502,12 +1507,14 @@ mod tests {
class TestComponent {}
"#;
assert_metadata(code, |meta| {
assert_eq!(meta.change_detection, ChangeDetectionStrategy::OnPush);
assert_eq!(meta.change_detection, Some(ChangeDetectionStrategy::OnPush));
});
}

#[test]
fn test_extract_change_detection_default() {
fn test_extract_change_detection_default_is_distinct() {
// `Default` (value 1) is the pre-v22 spelling of `Eager`. It is kept as
// a distinct variant so partial emit can preserve the author's member.
let code = r#"
@Component({
selector: 'app-test',
Expand All @@ -1517,13 +1524,13 @@ mod tests {
class TestComponent {}
"#;
assert_metadata(code, |meta| {
assert_eq!(meta.change_detection, ChangeDetectionStrategy::Default);
assert_eq!(meta.change_detection, Some(ChangeDetectionStrategy::Default));
});
}

#[test]
fn test_extract_change_detection_numeric_on_push() {
// Angular uses: Default=0, OnPush=1
fn test_extract_change_detection_numeric_eager() {
// Angular v22 numeric values: OnPush = 0, Eager = 1.
let code = r#"
@Component({
selector: 'app-test',
Expand All @@ -1533,12 +1540,15 @@ mod tests {
class TestComponent {}
"#;
assert_metadata(code, |meta| {
assert_eq!(meta.change_detection, ChangeDetectionStrategy::OnPush);
assert_eq!(meta.change_detection, Some(ChangeDetectionStrategy::Eager));
});
}

#[test]
fn test_change_detection_defaults_to_default() {
fn test_change_detection_unspecified_is_none() {
// When the decorator omits `changeDetection`, the metadata leaves it
// `None` so the emitter applies the target version's default (OnPush in
// v22+, Default/Eager before).
let code = r#"
@Component({
selector: 'app-test',
Expand All @@ -1547,7 +1557,7 @@ mod tests {
class TestComponent {}
"#;
assert_metadata(code, |meta| {
assert_eq!(meta.change_detection, ChangeDetectionStrategy::Default);
assert_eq!(meta.change_detection, None);
});
}

Expand Down Expand Up @@ -1938,7 +1948,7 @@ mod tests {
assert_eq!(meta.styles.len(), 1);
assert!(meta.standalone);
assert_eq!(meta.encapsulation, ViewEncapsulation::None);
assert_eq!(meta.change_detection, ChangeDetectionStrategy::OnPush);
assert_eq!(meta.change_detection, Some(ChangeDetectionStrategy::OnPush));
assert!(meta.host.is_some());
let host = meta.host.as_ref().unwrap();
assert_eq!(host.class_attr.as_ref().unwrap().as_str(), "app-complete");
Expand Down
61 changes: 29 additions & 32 deletions crates/oxc_angular_compiler/src/component/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,39 +485,36 @@ fn generate_cmp_definition<'a>(
));
}

// 24. changeDetection: ChangeDetectionStrategy.OnPush - only emit if not Default
// (to match TypeScript compiler behavior)
// Per Angular compiler.ts lines 334-346
// Angular enum values: OnPush = 0, Default = 1
// NOTE: Angular emits without namespace prefix (ChangeDetectionStrategy.OnPush, not i0.ChangeDetectionStrategy.OnPush)
if metadata.change_detection != ChangeDetectionStrategy::Default {
let strategy_name = match metadata.change_detection {
ChangeDetectionStrategy::Default => "Default",
ChangeDetectionStrategy::OnPush => "OnPush",
// 24. changeDetection — emit the resolved numeric value only when the
// explicit strategy differs from the value the runtime assumes when the
// field is omitted. That default flipped in v22:
// - v22+: default is OnPush (0); runtime `onPush = cd !== Eager`.
// - < v22: default is Default/Eager (1); runtime `onPush = cd === OnPush`.
// So pre-v22 emits `0` for an explicit OnPush while v22 emits `1` for Eager;
// each version omits its own default. Enum values: OnPush = 0, Eager = 1.
if let Some(strategy) = metadata.change_detection {
let value = match strategy {
ChangeDetectionStrategy::OnPush => 0,
// `Eager` and `Default` are the same numeric value (1); the AOT
// output is numeric, so the spelling distinction does not matter here.
ChangeDetectionStrategy::Eager | ChangeDetectionStrategy::Default => 1,
};
// Build: ChangeDetectionStrategy.OnPush (no i0 prefix)
// ReadPropExpr { receiver: ReadVarExpr("ChangeDetectionStrategy"), name: "OnPush" }
let change_detection_strategy_expr = OutputExpression::ReadVar(Box::new_in(
ReadVarExpr {
name: Ident::from(Identifiers::CHANGE_DETECTION_STRATEGY),
source_span: None,
},
allocator,
));
let strategy_value_expr = OutputExpression::ReadProp(Box::new_in(
ReadPropExpr {
receiver: Box::new_in(change_detection_strategy_expr, allocator),
name: Ident::from(strategy_name),
optional: false,
source_span: None,
},
allocator,
));
entries.push(LiteralMapEntry::new(
Ident::from("changeDetection"),
strategy_value_expr,
false,
));
let omitted_default =
if job.angular_version.map_or(true, |v| v.change_detection_default_is_on_push()) {
0 // v22+: OnPush is the default
} else {
1 // < v22: Default/Eager is the default
};
if value != omitted_default {
entries.push(LiteralMapEntry::new(
Ident::from("changeDetection"),
OutputExpression::Literal(Box::new_in(
LiteralExpr { value: LiteralValue::Number(value as f64), source_span: None },
allocator,
)),
false,
));
}
}

// Create the config object
Expand Down
55 changes: 52 additions & 3 deletions crates/oxc_angular_compiler/src/component/hoist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ use oxc_syntax::symbol::SymbolId;

use crate::optimizer::Edit;

use super::transform::{ImportMap, is_angular_core_export, is_angular_core_namespace};

/// Per-statement record collected during the initial scan. Multi-declarator
/// statements (`const A = 1, B = 2;`) get a single entry shared by every
/// symbol they bind; `init_symbols` is the union of identifier references
Expand Down Expand Up @@ -95,6 +97,7 @@ pub fn collect_hoist_edits<'a>(
program: &Program<'a>,
source: &str,
semantic: &Semantic<'a>,
import_map: &ImportMap<'a>,
) -> Vec<Edit> {
// Step 1: index top-level bindings (keyed by SymbolId).
// - `symbol_to_stmt`: binding SymbolId → containing statement's `start`.
Expand Down Expand Up @@ -159,7 +162,7 @@ pub fn collect_hoist_edits<'a>(
let mut classes: Vec<(&Class<'a>, u32, HashSet<SymbolId>, HashSet<SymbolId>)> = Vec::new();
for stmt in &program.body {
let Some((class, stmt_start_pos)) = class_of(stmt) else { continue };
if !has_angular_decorator(class) {
if !has_hoistable_angular_decorator(class, import_map) {
continue;
}
let mut direct: HashSet<SymbolId> = HashSet::new();
Expand Down Expand Up @@ -887,6 +890,11 @@ fn class_of<'a, 'src>(stmt: &'src Statement<'a>) -> Option<(&'src Class<'a>, u32
/// Does this class carry any decorator that Angular's compiler emits eager
/// definitions for? We don't try to be precise here — any of the well-known
/// Angular decorators makes the class a candidate.
///
/// Name-only and import-agnostic: used by the cheap pre-check
/// [`program_has_angular_decorated_class`], where a false positive only costs
/// a wasted scan. The actual hoist filter uses the import-aware
/// [`has_hoistable_angular_decorator`].
fn has_angular_decorator(class: &Class<'_>) -> bool {
class.decorators.iter().any(|d| {
let callee = match &d.expression {
Expand All @@ -898,7 +906,48 @@ fn has_angular_decorator(class: &Class<'_>) -> bool {
Expression::StaticMemberExpression(member) => member.property.name.as_str(),
_ => return false,
};
matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable")
matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable" | "Service")
Comment thread
brandonroberts marked this conversation as resolved.
})
}

/// Whether this class carries an Angular decorator that warrants hoisting its
/// referenced declarations.
///
/// Like [`has_angular_decorator`], but verifies `@Service` resolves to
/// `@angular/core` before treating it as Angular. `Service` is a common name
/// in non-Angular code, and hoisting a third-party `@Service` class's
/// referenced declarations would reorder statements and change that class's
/// runtime evaluation semantics. The other decorator names predate this check
/// and stay name-only — over-triggering there is the long-standing behavior and
/// only ever hoists a TDZ-safe declaration earlier.
fn has_hoistable_angular_decorator<'a>(class: &Class<'a>, import_map: &ImportMap<'a>) -> bool {
class.decorators.iter().any(|d| {
let callee = match &d.expression {
Expression::CallExpression(call) => &call.callee,
expr => expr,
};
match callee {
Expression::Identifier(id) => {
let name = id.name.as_str();
if name == "Service" {
is_angular_core_export(import_map, name, "Service")
} else {
matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable")
}
}
Expression::StaticMemberExpression(member) => {
let name = member.property.name.as_str();
if name == "Service" {
// `@ns.Service()` — accept only when `ns` is a namespace
// import from `@angular/core`.
matches!(&member.object, Expression::Identifier(ns)
if is_angular_core_namespace(import_map, ns.name.as_str()))
} else {
matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable")
}
}
_ => false,
}
})
}

Expand All @@ -907,7 +956,7 @@ fn has_angular_decorator(class: &Class<'_>) -> bool {
///
/// Used by the AOT transform pipeline to skip the `Semantic` build and the
/// full hoist scan for files with no decorated classes (plain TS helpers,
/// type-only modules, services without `@Injectable`, …). This walks
/// type-only modules, classes with no Angular decorator, …). This walks
/// `program.body` only and never descends into class bodies or expressions,
/// so it's O(top-level statements) with a tiny per-statement cost.
pub(crate) fn program_has_angular_decorated_class(program: &Program<'_>) -> bool {
Expand Down
36 changes: 30 additions & 6 deletions crates/oxc_angular_compiler/src/component/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ impl AngularVersion {
self.major >= 22
}

/// Whether the default component change-detection strategy is `OnPush`
/// (v22.0.0+).
///
/// Angular v22 made `OnPush` (0) the default strategy and added `Eager` (1).
/// The runtime computes `onPush = changeDetection !== Eager`, so the full
/// definition omits `changeDetection` for `OnPush` and emits `1` for `Eager`.
/// Before v22 the default was `Default`/`Eager` (1) and the runtime computed
/// `onPush = changeDetection === OnPush`, so the definition instead omits the
/// default and emits `0` for an explicit `OnPush`.
pub fn change_detection_default_is_on_push(&self) -> bool {
self.major >= 22
}

/// Check if this version emits control instructions for the extended set of
/// control properties (v22.0.0+).
///
Expand Down Expand Up @@ -163,11 +176,21 @@ pub enum ViewEncapsulation {
/// Matches Angular's `ChangeDetectionStrategy` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChangeDetectionStrategy {
/// Check the component on every change detection cycle.
/// Only check when inputs change or events are triggered. Angular v22's
/// default strategy (`OnPush = 0`).
#[default]
Default,
/// Only check when inputs change or events are triggered.
OnPush,
/// Check the component on every change detection cycle (`Eager = 1`). The
/// member introduced in Angular v22 to replace `Default`.
Eager,
/// Check the component on every change detection cycle. The pre-v22 spelling
/// (`Default = 1`), kept distinct from [`Eager`] so partial declarations
/// preserve the exact `ChangeDetectionStrategy` member the author wrote —
/// emitting `Eager` for an older Angular target would reference a member
/// that does not exist there.
///
/// [`Eager`]: ChangeDetectionStrategy::Eager
Default,
}

/// Metadata extracted from an `@Component` decorator.
Expand Down Expand Up @@ -203,8 +226,9 @@ pub struct ComponentMetadata<'a> {
/// View encapsulation mode.
pub encapsulation: ViewEncapsulation,

/// Change detection strategy.
pub change_detection: ChangeDetectionStrategy,
/// Change detection strategy, or `None` when the decorator does not specify
/// one (so the runtime's version-dependent default applies).
pub change_detection: Option<ChangeDetectionStrategy>,

/// Host bindings and listeners.
pub host: Option<HostMetadata<'a>>,
Expand Down Expand Up @@ -614,7 +638,7 @@ impl<'a> ComponentMetadata<'a> {
style_urls: Vec::new_in(allocator),
standalone: implicit_standalone,
encapsulation: ViewEncapsulation::default(),
change_detection: ChangeDetectionStrategy::default(),
change_detection: None,
host: None,
imports: Vec::new_in(allocator),
export_as: Vec::new_in(allocator),
Expand Down
11 changes: 8 additions & 3 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ fn find_angular_service_decorator<'a>(
/// the local binding is `Service`. Bare `import { Service }` (no alias)
/// passes because `imported_name` is `None`, meaning the local name and the
/// exported name agree.
fn is_angular_core_export(
pub(crate) fn is_angular_core_export(
import_map: &ImportMap<'_>,
local_name: &str,
exported_name: &str,
Expand All @@ -979,7 +979,7 @@ fn is_angular_core_export(
/// from `@angular/core`. Used to validate namespace-style decorator calls
/// like `@ns.Service()` — without this check, any third-party namespace
/// `Service` decorator would classify as the Angular v22 decorator.
fn is_angular_core_namespace(import_map: &ImportMap<'_>, local_name: &str) -> bool {
pub(crate) fn is_angular_core_namespace(import_map: &ImportMap<'_>, local_name: &str) -> bool {
import_map
.get(&Ident::from(local_name))
.map(|info| info.source_module.as_str() == "@angular/core" && !info.is_named_import)
Expand Down Expand Up @@ -3511,7 +3511,12 @@ pub fn transform_angular_file(
// The JIT path (see ~line 1380) follows the same convention.
let hoist_semantic =
oxc_semantic::SemanticBuilder::new().build(&parser_ret.program).semantic;
edits.extend(collect_hoist_edits(&parser_ret.program, source, &hoist_semantic));
edits.extend(collect_hoist_edits(
&parser_ret.program,
source,
&hoist_semantic,
&import_map,
));
}

// Apply all edits in one pass
Expand Down
Loading
Loading