diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 3c63ff805..5874aa0b3 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1630,7 +1630,8 @@ fn build_queries( /// - `hostDirectives: [...]` → `ns.ɵɵHostDirectivesFeature([...])` /// - `usesInheritance: true` → `ns.ɵɵInheritDefinitionFeature` /// - `usesOnChanges: true` → `ns.ɵɵNgOnChangesFeature` -/// Order is important: ProvidersFeature → HostDirectivesFeature → InheritDefinitionFeature → NgOnChangesFeature +/// - `controlCreate: { passThroughInput }` → `ns.ɵɵControlFeature(passThroughInput)` +/// Order is important: ProvidersFeature → HostDirectivesFeature → InheritDefinitionFeature → NgOnChangesFeature → ControlFeature /// (see packages/compiler/src/render3/view/compiler.ts:119-161) fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option { let mut features: Vec = Vec::new(); @@ -1666,6 +1667,17 @@ fn build_features(meta: &ObjectExpression<'_>, source: &str, ns: &str) -> Option features.push(format!("{ns}.\u{0275}\u{0275}NgOnChangesFeature")); } + // 5. ControlFeature — for signal form directives declaring controlCreate + // `controlCreate: { passThroughInput: "formField" | null }` + // emits `ɵɵControlFeature("formField")` or `ɵɵControlFeature(null)` + if let Some(control_create) = get_object_property(meta, "controlCreate") { + let pass_through = match get_string_property(control_create, "passThroughInput") { + Some(s) => format!("\"{s}\""), + None => "null".to_string(), + }; + features.push(format!("{ns}.\u{0275}\u{0275}ControlFeature({pass_through})")); + } + if features.is_empty() { None } else { Some(format!("[{}]", features.join(", "))) } } diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 4a6639ecf..129eb40e4 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -95,3 +95,29 @@ fn test_link_inputs_array_format_with_transform_function() { let result = link(&allocator, &code, "test.mjs"); insta::assert_snapshot!(result.code); } + +/// Regression: signal form FormField directive declares +/// `controlCreate: { passThroughInput: "formField" }` in its partial metadata. +/// The linker must emit `ɵɵControlFeature("formField")` in the features array, +/// otherwise `DirectiveDef.controlDef` is never set and the runtime +/// `ɵɵcontrolCreate()` / `ɵɵcontrol()` instructions become no-ops. +/// See voidzero-dev/oxc-angular-compiler#229. +#[test] +fn test_link_control_feature_pass_through_input() { + let allocator = Allocator::default(); + let code = r#"import * as i0 from "@angular/core"; +export class FormField {} +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 });"#; + let result = link(&allocator, code, "test.mjs"); + insta::assert_snapshot!(result.code); +} + +#[test] +fn test_link_control_feature_null_pass_through_input() { + let allocator = Allocator::default(); + let code = r#"import * as i0 from "@angular/core"; +export class MyControl {} +MyControl.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.8", type: MyControl, selector: "[myControl]", controlCreate: { passThroughInput: null }, isStandalone: true, isSignal: true });"#; + let result = link(&allocator, code, "test.mjs"); + insta::assert_snapshot!(result.code); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_null_pass_through_input.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_null_pass_through_input.snap new file mode 100644 index 000000000..f6c2673ff --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_null_pass_through_input.snap @@ -0,0 +1,7 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +expression: result.code +--- +import * as i0 from "@angular/core"; +export class MyControl {} +MyControl.ɵdir = i0.ɵɵdefineDirective({ type: MyControl, selectors: [["", "myControl", ""]], signals: true, features: [i0.ɵɵControlFeature(null)] }); diff --git a/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_pass_through_input.snap b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_pass_through_input.snap new file mode 100644 index 000000000..c4cb396e4 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/linker_test__link_control_feature_pass_through_input.snap @@ -0,0 +1,7 @@ +--- +source: crates/oxc_angular_compiler/tests/linker_test.rs +expression: result.code +--- +import * as i0 from "@angular/core"; +export class FormField {} +FormField.ɵdir = i0.ɵɵdefineDirective({ type: FormField, selectors: [["", "formField", ""]], inputs: { field: [1, "formField", "field"] }, signals: true, features: [i0.ɵɵControlFeature("formField")] }); diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/formfield-alias.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/formfield-alias.fixture.ts new file mode 100644 index 000000000..747c2442a --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/formfield-alias.fixture.ts @@ -0,0 +1,32 @@ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'formfield-alias-repro', + category: 'regressions', + description: 'Issue #229 repro: [formField] binding with aliased field input', + className: 'AppComponent', + type: 'full-transform', + sourceCode: ` +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { form, FormField, required } from '@angular/forms/signals'; + +@Component({ + selector: 'app-root', + imports: [FormField], + template: \`\`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppComponent { + protected readonly myFormModel = signal({ + firstName: 'Foo', + email: 'foo@bar.com', + }); + protected readonly myForm = form(this.myFormModel, path => { + required(path.firstName); + }); +} +`.trim(), + expectedFeatures: ['ɵɵproperty', 'ɵɵcontrol', 'ɵɵcontrolCreate'], + }, +]