From 90c1a1984e45dd52749f686a23a4b51e71bc8bb5 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Mon, 10 Aug 2026 13:11:35 +0100 Subject: [PATCH 1/2] fix(linker): resolve `changeDetection` default from the declaration version The linker emitted `changeDetection` only when the partial declaration carried the field, with no default for the omitted case. Which strategy an omitted field means depends on the version the library was compiled against: v22 made OnPush the default, so anything older meant `Eager`. The TS linker resolves that with `hasOnPushByDefault = major >= 22 || version === PLACEHOLDER_VERSION` in `partial_component_linker_1.ts`. Because the runtime derives `onPush = changeDetection !== Eager`, an absent field reads as OnPush. A pre-v22 component that never declared a strategy was therefore silently switched from Eager to OnPush, and stops re-rendering on anything that is not a signal or input change. Nothing is logged, so it surfaces only as stale UI. Six components in a typical `node_modules` hit this, including `AgGridAngular` (v17.3.12), `HighchartsChartComponent` (v16.2.12), `MarkdownComponent` (v21.2.5) and `_MatDialogContainerBase` (v16.1.1). Also stops emitting `changeDetection: 0` for an explicit OnPush. OnPush is the emit-time default and the TS compiler leaves it out (`meta.changeDetection !== OnPush` in `compileComponentFromMetadata`); the runtime infers it from the absent field. This is byte-identical in behaviour, and it clears the last `changeDetection` divergence when diffing linked output against the official linker across a real `node_modules`. --- crates/oxc_angular_compiler/src/linker/mod.rs | 105 +++++++++++++++++- 1 file changed, 101 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 918579fcc..1dac56d84 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -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: @@ -2065,9 +2083,18 @@ fn link_component( if let Some(cd) = get_property_source(meta, "changeDetection", source) { if cd.contains("Eager") || cd.contains("Default") { parts.push("changeDetection: 1".to_string()); - } else if cd.contains("OnPush") { - parts.push("changeDetection: 0".to_string()); } + // OnPush is the emit-time default and is left out: the runtime derives + // `onPush = changeDetection !== Eager`, so an absent field already reads + // as OnPush. Matches the `meta.changeDetection !== OnPush` guard in + // `compileComponentFromMetadata`. + } 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 = @@ -2743,9 +2770,12 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: " "#; let result = link(&allocator, code, "test.mjs"); assert!(result.linked); + // OnPush is the emit-time default, so the TS compiler leaves it out + // (`meta.changeDetection !== OnPush` in `compileComponentFromMetadata`); + // the runtime infers it from the absent field. assert!( - result.code.contains("changeDetection: 0"), - "ChangeDetectionStrategy.OnPush should be 0, got:\n{}", + !result.code.contains("changeDetection"), + "ChangeDetectionStrategy.OnPush is the default and should not be emitted, got:\n{}", result.code ); } @@ -2770,6 +2800,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: "
" }); +"#; + 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: "
" }); +"#; + 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: "
" }); +"#; + 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(); From cdd335380402f95b6a1bb94dc03de4b845245f5e Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Mon, 10 Aug 2026 14:01:37 +0100 Subject: [PATCH 2/2] fix(linker): keep emitting `changeDetection: 0` for an explicit OnPush Reverts the parity tweak that dropped the field for OnPush, per review. The TS compiler omits OnPush, but it ships with the runtime it targets. This linker does not know the consumer's version: `angularLinkerPlugin()` takes no options and `linkCode` calls `linkAngularPackage(code, id)`, and the plugin's `angularVersion` option documents support back to v19. Angular 20 and 21 compute `onPush = changeDetection === OnPush`, so an absent field reads as Default there and the component would silently lose OnPush. `0` is correct on both: pre-v22 `0 === 0`, and v22 `0 !== 1`. The version-gated default for an omitted field is unaffected, since `1` is likewise correct on both. --- crates/oxc_angular_compiler/src/linker/mod.rs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 1dac56d84..630116d56 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -2083,11 +2083,15 @@ fn link_component( if let Some(cd) = get_property_source(meta, "changeDetection", source) { 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()); } - // OnPush is the emit-time default and is left out: the runtime derives - // `onPush = changeDetection !== Eager`, so an absent field already reads - // as OnPush. Matches the `meta.changeDetection !== OnPush` guard in - // `compileComponentFromMetadata`. } 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, @@ -2770,12 +2774,14 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: " "#; let result = link(&allocator, code, "test.mjs"); assert!(result.linked); - // OnPush is the emit-time default, so the TS compiler leaves it out - // (`meta.changeDetection !== OnPush` in `compileComponentFromMetadata`); - // the runtime infers it from the absent field. - assert!( - !result.code.contains("changeDetection"), - "ChangeDetectionStrategy.OnPush is the default and should not be emitted, got:\n{}", + // 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{}", result.code ); }