Skip to content
Merged
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
103 changes: 103 additions & 0 deletions crates/oxc_angular_compiler/src/linker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,24 @@ fn get_default_standalone_value(meta: &ObjectExpression<'_>) -> bool {
true // If we can't determine the version, default to true (latest behavior)
}

/// Whether the declaration's `version` implies OnPush is the default change
/// detection strategy. Angular v22 made OnPush the default; anything compiled
/// against an earlier version meant `Eager`. The placeholder version used by dev
/// builds tracks the newest behaviour.
///
/// Mirrors `hasOnPushByDefault` in the TS linker's `partial_component_linker_1.ts`.
fn has_on_push_by_default(meta: &ObjectExpression<'_>) -> bool {
if let Some(version_str) = get_string_property(meta, "version") {
if version_str == "0.0.0-PLACEHOLDER" {
return true;
}
if let Ok(version) = semver::Version::parse(version_str) {
return version.major >= 22;
}
}
true // If we can't determine the version, default to true (latest behavior)
}

/// Extract the `deps` array from a factory metadata object and generate inject calls.
///
/// The `target` parameter determines the inject function and flags:
Expand Down Expand Up @@ -2066,8 +2084,21 @@ fn link_component(
if cd.contains("Eager") || cd.contains("Default") {
parts.push("changeDetection: 1".to_string());
} else if cd.contains("OnPush") {
// Emitted explicitly rather than left to the runtime default. The TS
// compiler omits OnPush here, but it ships with the runtime it targets;
// this linker does not know the consumer's version and supports back to
// v19. Pre-v22 runtimes compute `onPush = changeDetection === OnPush`,
// so an absent field reads as Default there and the component would
// silently lose OnPush. `0` is correct on both.
parts.push("changeDetection: 0".to_string());
}
} else if !has_on_push_by_default(meta) {
// Omitted. Which strategy that meant depends on the version the library
// was compiled against, and the runtime reads an absent field as OnPush,
// so a pre-v22 declaration has to say Eager out loud. Without this a
// component silently switches from Eager to OnPush and stops re-rendering
// on anything that is not a signal or input change.
parts.push("changeDetection: 1".to_string());
}

let define_component =
Expand Down Expand Up @@ -2743,6 +2774,11 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
// Emitted explicitly, even though the TS compiler omits OnPush. That
// compiler ships with the runtime it targets; this linker does not know
// the consumer's version and supports back to v19, where the runtime
// computes `onPush = changeDetection === OnPush` and so reads an absent
// field as Default. Omitting it would silently drop OnPush there.
assert!(
result.code.contains("changeDetection: 0"),
"ChangeDetectionStrategy.OnPush should be 0, got:\n{}",
Expand Down Expand Up @@ -2770,6 +2806,73 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "
);
}

// When a declaration omits `changeDetection`, the strategy it meant depends on
// the Angular version it was compiled with: v22 made OnPush the default, so
// anything older meant Eager. The TS linker resolves that with
// `hasOnPushByDefault = major >= 22 || version === PLACEHOLDER_VERSION`
// (partial_component_linker_1.ts). The runtime derives
// `onPush = changeDetection !== Eager`, so an omitted field reads as OnPush —
// which means a pre-v22 declaration has to emit `changeDetection: 1`
// explicitly, or the component silently switches strategy.

#[test]
fn test_link_component_pre_v22_without_change_detection_is_eager() {
let allocator = Allocator::default();
let code = r#"
import * as i0 from "@angular/core";
class MyComponent {
}
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: MyComponent, selector: "my-comp", template: "<div></div>" });
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
assert!(
result.code.contains("changeDetection: 1"),
"A pre-v22 declaration without changeDetection means Eager, so it must be emitted explicitly, got:\n{}",
result.code
);
}

#[test]
fn test_link_component_v22_without_change_detection_stays_on_push() {
// v22+ made OnPush the default, and the runtime already infers OnPush from
// an absent field, so nothing should be emitted.
let allocator = Allocator::default();
let code = r#"
import * as i0 from "@angular/core";
class MyComponent {
}
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.7", ngImport: i0, type: MyComponent, selector: "my-comp", template: "<div></div>" });
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
assert!(
!result.code.contains("changeDetection"),
"v22+ defaults to OnPush, which the runtime infers from an absent field, got:\n{}",
result.code
);
}

#[test]
fn test_link_component_placeholder_version_without_change_detection_stays_on_push() {
// `0.0.0-PLACEHOLDER` is what Angular's own dev builds ship; the TS linker
// treats it as the newest behaviour.
let allocator = Allocator::default();
let code = r#"
import * as i0 from "@angular/core";
class MyComponent {
}
MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, selector: "my-comp", template: "<div></div>" });
"#;
let result = link(&allocator, code, "test.mjs");
assert!(result.linked);
assert!(
!result.code.contains("changeDetection"),
"The placeholder version tracks the newest behaviour (OnPush), got:\n{}",
result.code
);
}

#[test]
fn test_link_component_with_host_attrs() {
let allocator = Allocator::default();
Expand Down
Loading