Skip to content

Commit fe0b0e6

Browse files
Brooooooklynclaude
andauthored
fix(angular): emit ɵɵControlFeature from linker for controlCreate metadata (#232)
The Angular Linker's build_features was ignoring the controlCreate property on ɵɵngDeclareDirective, so directives like @angular/forms/signals FormField were linked without ɵɵControlFeature(passThroughInput). This left DirectiveDef.controlDef unset at runtime, making template-emitted ɵɵcontrolCreate()/ɵɵcontrol() calls no-ops and breaking [formField] bindings with NG0950. Mirrors Angular TS compiler.ts:151-155. Fixes #229 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 568c2d3 commit fe0b0e6

File tree

5 files changed

+85
-1
lines changed

5 files changed

+85
-1
lines changed

crates/oxc_angular_compiler/src/linker/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,8 @@ fn build_queries(
16301630
/// - `hostDirectives: [...]` → `ns.ɵɵHostDirectivesFeature([...])`
16311631
/// - `usesInheritance: true` → `ns.ɵɵInheritDefinitionFeature`
16321632
/// - `usesOnChanges: true` → `ns.ɵɵNgOnChangesFeature`
1633-
/// Order is important: ProvidersFeature → HostDirectivesFeature → InheritDefinitionFeature → NgOnChangesFeature
1633+
/// - `controlCreate: { passThroughInput }` → `ns.ɵɵControlFeature(passThroughInput)`
1634+
/// Order is important: ProvidersFeature → HostDirectivesFeature → InheritDefinitionFeature → NgOnChangesFeature → ControlFeature
16341635
/// (see packages/compiler/src/render3/view/compiler.ts:119-161)
16351636
fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option<String> {
16361637
let mut features: Vec<String> = Vec::new();
@@ -1666,6 +1667,17 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option
16661667
features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature"));
16671668
}
16681669

1670+
// 5. ControlFeature — for signal form directives declaring controlCreate
1671+
// `controlCreate: { passThroughInput: "formField" | null }`
1672+
// emits `ɵɵControlFeature("formField")` or `ɵɵControlFeature(null)`
1673+
if let Some(control_create) = get_object_property(meta, "controlCreate") {
1674+
let pass_through = match get_string_property(control_create, "passThroughInput") {
1675+
Some(s) => format!("\"{s}\""),
1676+
None => "null".to_string(),
1677+
};
1678+
features.push(format!("{ns}.\u{0275}\u{0275}ControlFeature({pass_through})"));
1679+
}
1680+
16691681
if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) }
16701682
}
16711683

crates/oxc_angular_compiler/tests/linker_test.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,29 @@ fn test_link_inputs_array_format_with_transform_function() {
9595
let result = link(&allocator, &code, "test.mjs");
9696
insta::assert_snapshot!(result.code);
9797
}
98+
99+
/// Regression: signal form FormField directive declares
100+
/// `controlCreate: { passThroughInput: "formField" }` in its partial metadata.
101+
/// The linker must emit `ɵɵControlFeature("formField")` in the features array,
102+
/// otherwise `DirectiveDef.controlDef` is never set and the runtime
103+
/// `ɵɵcontrolCreate()` / `ɵɵcontrol()` instructions become no-ops.
104+
/// See voidzero-dev/oxc-angular-compiler#229.
105+
#[test]
106+
fn test_link_control_feature_pass_through_input() {
107+
let allocator = Allocator::default();
108+
let code = r#"import * as i0 from "@angular/core";
109+
export class FormField {}
110+
FormField.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.8", type: FormField, selector: "[formField]", inputs: { field: { classPropertyName: "field", publicName: "formField", isRequired: true, isSignal: true } }, controlCreate: { passThroughInput: "formField" }, isStandalone: true, isSignal: true });"#;
111+
let result = link(&allocator, code, "test.mjs");
112+
insta::assert_snapshot!(result.code);
113+
}
114+
115+
#[test]
116+
fn test_link_control_feature_null_pass_through_input() {
117+
let allocator = Allocator::default();
118+
let code = r#"import * as i0 from "@angular/core";
119+
export class MyControl {}
120+
MyControl.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.8", type: MyControl, selector: "[myControl]", controlCreate: { passThroughInput: null }, isStandalone: true, isSignal: true });"#;
121+
let result = link(&allocator, code, "test.mjs");
122+
insta::assert_snapshot!(result.code);
123+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
expression: result.code
4+
---
5+
import * as i0 from "@angular/core";
6+
export class MyControl {}
7+
MyControldir = i0.ɵɵdefineDirective({ type: MyControl, selectors: [["", "myControl", ""]], signals: true, features: [i0.ɵɵControlFeature(null)] });
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/oxc_angular_compiler/tests/linker_test.rs
3+
expression: result.code
4+
---
5+
import * as i0 from "@angular/core";
6+
export class FormField {}
7+
FormFielddir = i0.ɵɵdefineDirective({ type: FormField, selectors: [["", "formField", ""]], inputs: { field: [1, "formField", "field"] }, signals: true, features: [i0.ɵɵControlFeature("formField")] });
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Fixture } from '../types.js'
2+
3+
export const fixtures: Fixture[] = [
4+
{
5+
name: 'formfield-alias-repro',
6+
category: 'regressions',
7+
description: 'Issue #229 repro: [formField] binding with aliased field input',
8+
className: 'AppComponent',
9+
type: 'full-transform',
10+
sourceCode: `
11+
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
12+
import { form, FormField, required } from '@angular/forms/signals';
13+
14+
@Component({
15+
selector: 'app-root',
16+
imports: [FormField],
17+
template: \`<input type="text" [formField]="myForm.firstName" />\`,
18+
changeDetection: ChangeDetectionStrategy.OnPush,
19+
})
20+
export class AppComponent {
21+
protected readonly myFormModel = signal({
22+
firstName: 'Foo',
23+
email: 'foo@bar.com',
24+
});
25+
protected readonly myForm = form(this.myFormModel, path => {
26+
required(path.firstName);
27+
});
28+
}
29+
`.trim(),
30+
expectedFeatures: ['ɵɵproperty', 'ɵɵcontrol', 'ɵɵcontrolCreate'],
31+
},
32+
]

0 commit comments

Comments
 (0)