From c4ce3f345fdb14595f0991dff488c4043a0fc71c Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 27 Jan 2026 17:33:10 -0800 Subject: [PATCH] feat(forms): template & reactive support for FVC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement support for `FormUiComponent`s in both Reactive and Template-driven forms. This allows components that use the new signal-based form control architecture to be used seamlessly within existing Angular form paradigms. Key changes: - Integrated `ɵngControlCreate` and `ɵngControlUpdate` lifecycle hooks into `NgModel`, `FormControlDirective`, and `FormControlName`. - Implemented branching logic to choose between the traditional `ControlValueAccessor` (CVA) path and the new FVC path based on the host element's capabilities. - Added comprehensive unit tests for FVC integration in both Reactive (`reactive_fvc.spec.ts`) and Template-driven (`template_fvc.spec.ts`) forms, covering: - Value synchronization (model -> view and view -> model). - Status synchronization (touched, dirty, valid, invalid, pending, required). - Error propagation and `parseErrors` support. - Fallback behavior to native DOM properties (disabled, required) when FVC inputs are missing. - Graceful fallback to CVA when no FVC pattern is detected. - Refined `NgModel` to correctly handle `required` validation via its existing `RequiredValidator` directive while supporting FVC for other properties. --- goldens/public-api/forms/index.api.md | 1 + .../nested_two_way_template.js | 2 + .../simple_two_way_template.js | 2 + ...inding_to_signal_loop_variable_template.js | 2 + .../two_way_to_any_template.js | 2 + .../pipeline/src/phases/control_directives.ts | 3 + .../forms_reactive/bundle.golden_symbols.json | 11 + .../bundle.golden_symbols.json | 11 + .../signals/test/web/reactive_fvc.spec.ts | 762 ++++++++++++++++++ .../signals/test/web/template_fvc.spec.ts | 469 +++++++++++ packages/forms/src/directives/ng_model.ts | 45 +- .../form_control_directive.ts | 37 +- .../reactive_directives/form_control_name.ts | 45 +- .../forms/test/reactive_integration_spec.ts | 25 + 14 files changed, 1405 insertions(+), 12 deletions(-) create mode 100644 packages/forms/signals/test/web/reactive_fvc.spec.ts create mode 100644 packages/forms/signals/test/web/template_fvc.spec.ts diff --git a/goldens/public-api/forms/index.api.md b/goldens/public-api/forms/index.api.md index b775d4860a8..7b96b546048 100644 --- a/goldens/public-api/forms/index.api.md +++ b/goldens/public-api/forms/index.api.md @@ -778,6 +778,7 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { updateOn?: FormHooks; }; get path(): string[]; + protected get shouldBindRequired(): boolean; update: EventEmitter; viewModel: any; viewToModelUpdate(newValue: any): void; diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js index 1566be1ea29..d61182c6906 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/nested_two_way_template.js @@ -9,8 +9,10 @@ function TestCmp_ng_template_1_Template(rf, ctx) { return $r3$.ɵɵresetView($event); }); $r3$.ɵɵelementEnd(); + $r3$.ɵɵcontrolCreate(); } if (rf & 2) { const $ctx_r0$ = $r3$.ɵɵnextContext(); $r3$.ɵɵtwoWayProperty("ngModel", $ctx_r0$.name); + $r3$.ɵɵcontrol(); } } \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js index 6b4ecd0b4f3..52b0d3f6946 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/simple_two_way_template.js @@ -7,8 +7,10 @@ function TestCmp_Template(rf, ctx) { return $event; }); $r3$.ɵɵelementEnd(); + $r3$.ɵɵcontrolCreate(); } if (rf & 2) { $r3$.ɵɵadvance(); $r3$.ɵɵtwoWayProperty("ngModel", ctx.name); + $r3$.ɵɵcontrol(); } } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_binding_to_signal_loop_variable_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_binding_to_signal_loop_variable_template.js index 27844f0b482..44f9199f958 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_binding_to_signal_loop_variable_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_binding_to_signal_loop_variable_template.js @@ -8,10 +8,12 @@ function TestCmp_For_1_Template(rf, ctx) { return $r3$.ɵɵresetView($event); }); $r3$.ɵɵelementEnd(); + $r3$.ɵɵcontrolCreate(); } if (rf & 2) { const $name_r2$ = ctx.$implicit; $r3$.ɵɵtwoWayProperty("ngModel", $name_r2$); + $r3$.ɵɵcontrol(); } } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_to_any_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_to_any_template.js index 5f560f31d55..e57ec47f83e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_to_any_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/two_way_to_any_template.js @@ -6,8 +6,10 @@ function TestCmp_Template(rf, ctx) { return $event; }); $r3$.ɵɵelementEnd(); + $r3$.ɵɵcontrolCreate(); } if (rf & 2) { $r3$.ɵɵtwoWayProperty("ngModel", ctx.value); + $r3$.ɵɵcontrol(); } } diff --git a/packages/compiler/src/template/pipeline/src/phases/control_directives.ts b/packages/compiler/src/template/pipeline/src/phases/control_directives.ts index dcbc6b301d9..8f568f59664 100644 --- a/packages/compiler/src/template/pipeline/src/phases/control_directives.ts +++ b/packages/compiler/src/template/pipeline/src/phases/control_directives.ts @@ -11,6 +11,9 @@ import type {ComponentCompilationJob, ViewCompilationUnit} from '../compilation' const ELIGIBLE_CONTROL_PROPERTIES = new Map>([ ['formField', new Set([ir.OpKind.Property])], + ['formControl', new Set([ir.OpKind.Property])], + ['formControlName', new Set([ir.OpKind.Property, ir.OpKind.Attribute])], + ['ngModel', new Set([ir.OpKind.Attribute, ir.OpKind.Property, ir.OpKind.TwoWayProperty])], ]); export function specializeControlProperties(job: ComponentCompilationJob): void { diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 4f60eece046..6460675fb9d 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -65,6 +65,7 @@ "ComponentRef2", "ConsumerObserver", "ControlContainer", + "ControlDirectiveHostImpl", "ControlEvent", "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", @@ -287,12 +288,15 @@ "\\u0275FORM_FIELD_PARSE_ERRORS", "\\u0275InternalFormsSharedModule", "\\u0275NgNoValidate", + "\\u0275\\u0275ControlFeature", "\\u0275\\u0275InheritDefinitionFeature", "\\u0275\\u0275NgOnChangesFeature", "\\u0275\\u0275ProvidersFeature", "\\u0275\\u0275advance", "\\u0275\\u0275attribute", "\\u0275\\u0275classProp", + "\\u0275\\u0275control", + "\\u0275\\u0275controlCreate", "\\u0275\\u0275defineComponent", "\\u0275\\u0275defineDirective", "\\u0275\\u0275defineInjectable", @@ -441,8 +445,10 @@ "consumerMarkDirty", "consumerPollProducersForChange", "context", + "controlCreateInternal", "controlNameBinding", "controlPath", + "controlUpdateInternal", "convertToBitFlags", "convertToInjectOptions", "couldBeInjectableType", @@ -659,9 +665,12 @@ "hasClassInput", "hasDeps", "hasInSkipHydrationBlockFlag", + "hasInput", "hasInvalidParent", "hasLift", + "hasModelInput", "hasOnDestroy", + "hasOutput", "hasParentInjector", "hasStyleInput", "hasStylingInputShadow", @@ -683,6 +692,8 @@ "initDomAdapter", "initFeatures", "initTNodeFlags", + "initializeControlFirstCreatePass", + "initializeCustomControlStatus", "initializeDirectives", "initializeElement", "initializeInputAndOutputAliases", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 2ae3dee81f5..456f9c87f86 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -64,6 +64,7 @@ "ComponentRef2", "ConsumerObserver", "ControlContainer", + "ControlDirectiveHostImpl", "ControlEvent", "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", @@ -281,12 +282,15 @@ "\\u0275FORM_FIELD_PARSE_ERRORS", "\\u0275InternalFormsSharedModule", "\\u0275NgNoValidate", + "\\u0275\\u0275ControlFeature", "\\u0275\\u0275InheritDefinitionFeature", "\\u0275\\u0275NgOnChangesFeature", "\\u0275\\u0275ProvidersFeature", "\\u0275\\u0275advance", "\\u0275\\u0275attribute", "\\u0275\\u0275classProp", + "\\u0275\\u0275control", + "\\u0275\\u0275controlCreate", "\\u0275\\u0275defineComponent", "\\u0275\\u0275defineDirective", "\\u0275\\u0275defineInjectable", @@ -439,7 +443,9 @@ "consumerMarkDirty", "consumerPollProducersForChange", "context", + "controlCreateInternal", "controlPath", + "controlUpdateInternal", "convertToBitFlags", "convertToInjectOptions", "couldBeInjectableType", @@ -655,8 +661,11 @@ "hasClassInput", "hasDeps", "hasInSkipHydrationBlockFlag", + "hasInput", "hasLift", + "hasModelInput", "hasOnDestroy", + "hasOutput", "hasParentInjector", "hasStyleInput", "hasStylingInputShadow", @@ -678,6 +687,8 @@ "initDomAdapter", "initFeatures", "initTNodeFlags", + "initializeControlFirstCreatePass", + "initializeCustomControlStatus", "initializeDirectives", "initializeElement", "initializeInputAndOutputAliases", diff --git a/packages/forms/signals/test/web/reactive_fvc.spec.ts b/packages/forms/signals/test/web/reactive_fvc.spec.ts new file mode 100644 index 00000000000..42e21f56b70 --- /dev/null +++ b/packages/forms/signals/test/web/reactive_fvc.spec.ts @@ -0,0 +1,762 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, computed, Directive, inject, input, model, Self, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import { + FormControl, + FormGroup, + NgControl, + ReactiveFormsModule, + Validators, + ɵFORM_FIELD_PARSE_ERRORS, + ControlValueAccessor, +} from '@angular/forms'; + +@Component({ + selector: 'my-fvc-input', + template: '', +}) +class MyFvcInput { + readonly value = model(''); + readonly disabled = input(false); + readonly touched = input(false); + readonly dirty = input(false); + readonly valid = input(true); + readonly invalid = input(false); + readonly pending = input(false); + readonly required = input(false); + readonly errors = input([]); +} + +describe('FormControlDirective with FVC', () => { + it('should sync FormControl value to FVC (model -> view)', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('initial'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.value()).toBe('initial'); + + act(() => fixture.componentInstance.ctrl.setValue('updated')); + expect(fvc.value()).toBe('updated'); + }); + + it('should sync FVC value to FormControl (view -> model)', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('initial'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + act(() => fvc.value.set('from-fvc')); + + expect(fixture.componentInstance.ctrl.value).toBe('from-fvc'); + }); + + it('should fall back to CVA when no FVC pattern is present', () => { + @Component({ + template: ``, + imports: [ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('initial'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + expect(input.value).toBe('initial'); + + act(() => fixture.componentInstance.ctrl.setValue('updated')); + expect(input.value).toBe('updated'); + }); + + it('should fall back to CVA when NgControl.valueAccessor is assigned directly', () => { + // A directive that acts like a CVA but assigns itself directly instead of via DI + @Directive({ + selector: '[customDirectCva]', + }) + class CustomDirectCva implements ControlValueAccessor { + constructor(@Self() ngControl: NgControl) { + ngControl.valueAccessor = this; + } + + writtenValue: any; + + writeValue(obj: any): void { + this.writtenValue = obj; + } + registerOnChange(fn: any): void {} + registerOnTouched(fn: any): void {} + } + + @Component({ + template: ``, + imports: [CustomDirectCva, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('initial'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const cva = fixture.debugElement + .query(By.directive(CustomDirectCva)) + .injector.get(CustomDirectCva); + + expect(cva.writtenValue).toBe('initial'); + + act(() => fixture.componentInstance.ctrl.setValue('updated')); + expect(cva.writtenValue).toBe('updated'); + }); + + describe('status bindings', () => { + it('should sync disabled state to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.disabled()).toBe(false); + + act(() => fixture.componentInstance.ctrl.disable()); + expect(fvc.disabled()).toBe(true); + + act(() => fixture.componentInstance.ctrl.enable()); + expect(fvc.disabled()).toBe(false); + }); + + it('should sync touched state to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.touched()).toBe(false); + + act(() => fixture.componentInstance.ctrl.markAsTouched()); + expect(fvc.touched()).toBe(true); + + act(() => fixture.componentInstance.ctrl.markAsUntouched()); + expect(fvc.touched()).toBe(false); + }); + + it('should sync dirty state to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.dirty()).toBe(false); + + act(() => fixture.componentInstance.ctrl.markAsDirty()); + expect(fvc.dirty()).toBe(true); + + act(() => fixture.componentInstance.ctrl.markAsPristine()); + expect(fvc.dirty()).toBe(false); + }); + + it('should sync valid/invalid state to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test', Validators.required); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + + act(() => fixture.componentInstance.ctrl.setValue('')); + expect(fvc.valid()).toBe(false); + expect(fvc.invalid()).toBe(true); + + act(() => fixture.componentInstance.ctrl.setValue('valid')); + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + }); + + it('should sync pending state to FVC', async () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.pending()).toBe(false); + + // Add async validator that never resolves to keep pending state + let resolveValidator: () => void; + const asyncValidator = () => + new Promise((resolve) => { + resolveValidator = () => resolve(null); + }); + + act(() => { + fixture.componentInstance.ctrl.addAsyncValidators(asyncValidator); + fixture.componentInstance.ctrl.updateValueAndValidity(); + }); + + expect(fvc.pending()).toBe(true); + + // Resolve the validator + resolveValidator!(); + await fixture.whenStable(); + act(() => {}); + + expect(fvc.pending()).toBe(false); + }); + + it('should fall back to native disabled property when FVC lacks disabled input', () => { + // An FVC directive placed on a native input that lacks a disabled input + @Directive({ + selector: '[fvcNoDisabled]', + }) + class FvcNoDisabledDirective { + readonly value = model(''); // FVC with value model but NO disabled input + } + + @Component({ + template: ``, + imports: [FvcNoDisabledDirective, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const inputEl = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + + expect(inputEl.disabled).toBe(false); + + act(() => fixture.componentInstance.ctrl.disable()); + expect(inputEl.disabled).toBe(true); + + act(() => fixture.componentInstance.ctrl.enable()); + expect(inputEl.disabled).toBe(false); + }); + + it('should prefer FVC disabled input over native fallback when FVC has the input', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('test'); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + const inputEl = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + + // Initially both FVC and native should reflect enabled state + expect(fvc.disabled()).toBe(false); + expect(inputEl.disabled).toBe(false); + + // Disable - FVC's disabled input is used, which reflects to native via FVC's template + act(() => fixture.componentInstance.ctrl.disable()); + expect(fvc.disabled()).toBe(true); + expect(inputEl.disabled).toBe(true); // Set via FVC's template binding [disabled]="disabled()" + + // Enable - both should be enabled again + act(() => fixture.componentInstance.ctrl.enable()); + expect(fvc.disabled()).toBe(false); + expect(inputEl.disabled).toBe(false); + }); + }); + + describe('error bindings', () => { + it('should sync FormControl errors to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('', Validators.required); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + + // Empty value should have required error + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('required'); + + // Valid value should clear errors + act(() => fixture.componentInstance.ctrl.setValue('valid')); + expect(fvc.errors().length).toBe(0); + }); + + it('should sync multiple errors to FVC', () => { + @Component({ + template: ``, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('ab', [Validators.required, Validators.minLength(5)]); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + + // Should have minLength error (value is 'ab' which is less than 5) + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('minlength'); + expect(fvc.errors()[0].context).toEqual({requiredLength: 5, actualLength: 2}); + + // Empty value should have required error (minLength doesn't validate empty) + act(() => fixture.componentInstance.ctrl.setValue('')); + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('required'); + + // Valid value should clear all errors + act(() => fixture.componentInstance.ctrl.setValue('valid')); + expect(fvc.errors().length).toBe(0); + }); + }); +}); + +describe('error bindings', () => { + it('should sync FVC parseErrors to FormControl', () => { + @Component({ + selector: 'my-parsing-input', + template: '', + }) + class MyParsingInput { + readonly value = model(''); + constructor() { + const parseErrorsToken = inject(ɵFORM_FIELD_PARSE_ERRORS, {optional: true, self: true}); + if (parseErrorsToken) { + parseErrorsToken.set( + computed(() => { + return this.value() === 'INVALID' ? [{kind: 'parse', reason: 'cannot parse'}] : []; + }), + ); + } + } + } + + @Component({ + template: ``, + imports: [MyParsingInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl(''); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyParsingInput)).componentInstance; + + // Initially no errors + expect(fixture.componentInstance.ctrl.errors).toBeNull(); + + // Set value that triggers parse error + act(() => fvc.value.set('INVALID')); + expect(fixture.componentInstance.ctrl.errors).toEqual({ + parse: {kind: 'parse', reason: 'cannot parse'}, + }); + + // Clear parse error + act(() => fvc.value.set('valid')); + expect(fixture.componentInstance.ctrl.errors).toBeNull(); + }); + + it('should merge parseErrors with validator errors', () => { + @Component({ + selector: 'my-parsing-input', + template: '', + }) + class MyParsingInput { + readonly value = model(''); + constructor() { + const parseErrorsToken = inject(ɵFORM_FIELD_PARSE_ERRORS, {optional: true, self: true}); + if (parseErrorsToken) { + parseErrorsToken.set(computed(() => (this.value() === 'BAD' ? [{kind: 'parse'}] : []))); + } + } + } + + @Component({ + template: ``, + imports: [MyParsingInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = new FormControl('', Validators.minLength(5)); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyParsingInput)).componentInstance; + + // Set value that triggers parse error (BAD = 3 chars, also minLength error) + act(() => fvc.value.set('BAD')); + expect(fixture.componentInstance.ctrl.errors).toEqual({ + parse: {kind: 'parse'}, + minlength: {requiredLength: 5, actualLength: 3}, + }); + + // Set value that won't trigger a parse error - only minLength check + act(() => fvc.value.set('abc')); // 3 chars + expect(fixture.componentInstance.ctrl.errors).toEqual({ + minlength: {requiredLength: 5, actualLength: 3}, + }); + + // Set valid long value - no errors + act(() => fvc.value.set('valid-long')); + expect(fixture.componentInstance.ctrl.errors).toBeNull(); + }); + + it('should switch parseErrors to new FormControl when swapped', () => { + @Component({ + selector: 'my-parsing-input', + template: '', + }) + class MyParsingInput { + readonly value = model(''); + constructor() { + const parseErrorsToken = inject(ɵFORM_FIELD_PARSE_ERRORS, {optional: true, self: true}); + if (parseErrorsToken) { + parseErrorsToken.set(computed(() => (this.value() === 'BAD' ? [{kind: 'parse'}] : []))); + } + } + } + + @Component({ + template: ``, + imports: [MyParsingInput, ReactiveFormsModule], + }) + class TestCmp { + ctrl = signal(new FormControl('')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyParsingInput)).componentInstance; + const oldCtrl = fixture.componentInstance.ctrl(); + + // Trigger parse error on old control + act(() => fvc.value.set('BAD')); + expect(oldCtrl.errors).toEqual({parse: {kind: 'parse'}}); + + // Swap to new FormControl - errors should move to new control + const newCtrl = new FormControl(''); + act(() => fixture.componentInstance.ctrl.set(newCtrl)); + + // Old control should no longer have parse errors (validator removed) + expect(oldCtrl.errors).toBeNull(); + // New control should not have parse errors yet (validator added, but value is '') + expect(newCtrl.errors).toBeNull(); + + // Trigger parse error again - should only affect new control + act(() => fvc.value.set('BAD')); + expect(oldCtrl.errors).toBeNull(); + expect(newCtrl.errors).toEqual({parse: {kind: 'parse'}}); + + // Clear parse error - should affect new control only + act(() => fvc.value.set('good')); + expect(oldCtrl.errors).toBeNull(); + expect(newCtrl.errors).toBeNull(); + }); +}); + +describe('FormControlName with FVC', () => { + it('should sync FormControl value to FVC (model -> view)', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('initial'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.value()).toBe('initial'); + + act(() => fixture.componentInstance.form.controls.name.setValue('updated')); + expect(fvc.value()).toBe('updated'); + }); + + it('should sync FVC value to FormControl (view -> model)', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('initial'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + act(() => fvc.value.set('from-fvc')); + + expect(fixture.componentInstance.form.controls.name.value).toBe('from-fvc'); + }); + + it('should fall back to CVA when no FVC pattern is present', () => { + @Component({ + template: ` +
+ +
+ `, + imports: [ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('initial'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + expect(input.value).toBe('initial'); + + act(() => fixture.componentInstance.form.controls.name.setValue('updated')); + expect(input.value).toBe('updated'); + }); + + describe('status bindings', () => { + it('should sync disabled state to FVC', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('test'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.disabled()).toBe(false); + + act(() => fixture.componentInstance.form.controls.name.disable()); + expect(fvc.disabled()).toBe(true); + + act(() => fixture.componentInstance.form.controls.name.enable()); + expect(fvc.disabled()).toBe(false); + }); + + it('should sync touched state to FVC', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('test'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.touched()).toBe(false); + + act(() => fixture.componentInstance.form.controls.name.markAsTouched()); + expect(fvc.touched()).toBe(true); + + act(() => fixture.componentInstance.form.controls.name.markAsUntouched()); + expect(fvc.touched()).toBe(false); + }); + + it('should sync dirty state to FVC', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('test'), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.dirty()).toBe(false); + + act(() => fixture.componentInstance.form.controls.name.markAsDirty()); + expect(fvc.dirty()).toBe(true); + + act(() => fixture.componentInstance.form.controls.name.markAsPristine()); + expect(fvc.dirty()).toBe(false); + }); + + it('should sync valid/invalid state to FVC', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('test', Validators.required), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + + act(() => fixture.componentInstance.form.controls.name.setValue('')); + expect(fvc.valid()).toBe(false); + expect(fvc.invalid()).toBe(true); + + act(() => fixture.componentInstance.form.controls.name.setValue('valid')); + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + }); + }); + + describe('error bindings', () => { + it('should sync FormControl errors to FVC', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('', Validators.required), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + + // Empty value should have required error + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('required'); + + // Valid value should clear errors + act(() => fixture.componentInstance.form.controls.name.setValue('valid')); + expect(fvc.errors().length).toBe(0); + }); + }); + + describe('required binding', () => { + it('should sync required status to FVC based on Validators.required', () => { + @Component({ + template: ` +
+ + + `, + imports: [MyFvcInput, ReactiveFormsModule], + }) + class TestCmp { + form = new FormGroup({ + name: new FormControl('', Validators.required), + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyFvcInput)).componentInstance; + + expect(fvc.required()).toBe(true); + + // Remove required validator + act(() => { + fixture.componentInstance.form.controls.name.removeValidators(Validators.required); + fixture.componentInstance.form.controls.name.updateValueAndValidity(); + }); + expect(fvc.required()).toBe(false); + + // Add required validator back + act(() => { + fixture.componentInstance.form.controls.name.addValidators(Validators.required); + fixture.componentInstance.form.controls.name.updateValueAndValidity(); + }); + expect(fvc.required()).toBe(true); + }); + }); +}); + +function act(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +} diff --git a/packages/forms/signals/test/web/template_fvc.spec.ts b/packages/forms/signals/test/web/template_fvc.spec.ts new file mode 100644 index 00000000000..31441b22976 --- /dev/null +++ b/packages/forms/signals/test/web/template_fvc.spec.ts @@ -0,0 +1,469 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ApplicationRef, + Component, + computed, + Directive, + inject, + input, + model, + signal, + ViewChild, +} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {FormsModule, NgModel, Validators, ɵFORM_FIELD_PARSE_ERRORS} from '@angular/forms'; + +@Component({ + selector: 'template-fvc-input', + template: '', +}) +class TemplateFvcInput { + readonly value = model(''); + readonly disabled = input(false); + readonly touched = input(false); + readonly dirty = input(false); + readonly valid = input(true); + readonly invalid = input(false); + readonly pending = input(false); + readonly required = input(false); + readonly errors = input([]); +} + +describe('NgModel with FVC', () => { + it('should sync ngModel value to FVC (model -> view)', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('initial'); + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.value()).toBe('initial'); + + await actAsync(() => fixture.componentInstance.val.set('updated')); + expect(fvc.value()).toBe('updated'); + }); + + it('should sync FVC value to ngModel (view -> model)', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('initial'); + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + act(() => fvc.value.set('from-fvc')); + + expect(fixture.componentInstance.val()).toBe('from-fvc'); + }); + + it('should fall back to CVA when no FVC pattern is present', async () => { + @Component({ + template: ``, + imports: [FormsModule], + }) + class TestCmp { + val = signal('initial'); + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + expect(input.value).toBe('initial'); + + await actAsync(() => fixture.componentInstance.val.set('updated')); + expect(input.value).toBe('updated'); + }); + + describe('status bindings', () => { + it('should sync disabled state to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.disabled()).toBe(false); + + act(() => fixture.componentInstance.model.control.disable()); + expect(fvc.disabled()).toBe(true); + + act(() => fixture.componentInstance.model.control.enable()); + expect(fvc.disabled()).toBe(false); + }); + + it('should sync touched state to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.touched()).toBe(false); + + act(() => fixture.componentInstance.model.control.markAsTouched()); + expect(fvc.touched()).toBe(true); + + act(() => fixture.componentInstance.model.control.markAsUntouched()); + expect(fvc.touched()).toBe(false); + }); + + it('should sync dirty state to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.dirty()).toBe(false); + + act(() => fixture.componentInstance.model.control.markAsDirty()); + expect(fvc.dirty()).toBe(true); + + act(() => fixture.componentInstance.model.control.markAsPristine()); + expect(fvc.dirty()).toBe(false); + }); + + it('should sync valid/invalid state to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + + // Add required validator programmatically + await actAsync(() => { + fixture.componentInstance.model.control.addValidators(Validators.required); + fixture.componentInstance.model.control.updateValueAndValidity(); + fixture.componentInstance.val.set(''); + }); + expect(fvc.valid()).toBe(false); + expect(fvc.invalid()).toBe(true); + + await actAsync(() => fixture.componentInstance.val.set('valid')); + expect(fvc.valid()).toBe(true); + expect(fvc.invalid()).toBe(false); + }); + + it('should sync pending state to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + expect(fvc.pending()).toBe(false); + + // Add async validator that never resolves to keep pending state + let resolveValidator: () => void; + const asyncValidator = () => + new Promise((resolve) => { + resolveValidator = () => resolve(null); + }); + + act(() => { + fixture.componentInstance.model.control.addAsyncValidators(asyncValidator); + fixture.componentInstance.model.control.updateValueAndValidity(); + }); + + expect(fvc.pending()).toBe(true); + + // Resolve the validator + resolveValidator!(); + await fixture.whenStable(); + act(() => {}); + + expect(fvc.pending()).toBe(false); + }); + + it('should fall back to native disabled property when FVC lacks disabled input', async () => { + // An FVC directive placed on a native input that lacks a disabled input + @Directive({ + selector: '[fvcNoDisabled]', + }) + class FvcNoDisabledDirective { + readonly value = model(''); // FVC with value model but NO disabled input + } + + @Component({ + template: ``, + imports: [FvcNoDisabledDirective, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const inputEl = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + + expect(inputEl.disabled).toBe(false); + + act(() => fixture.componentInstance.model.control.disable()); + expect(inputEl.disabled).toBe(true); + + act(() => fixture.componentInstance.model.control.enable()); + expect(inputEl.disabled).toBe(false); + }); + + it('should prefer FVC disabled input over native fallback when FVC has the input', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('test'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + const inputEl = fixture.debugElement.query(By.css('input')).nativeElement as HTMLInputElement; + + // Initially both FVC and native should reflect enabled state + expect(fvc.disabled()).toBe(false); + expect(inputEl.disabled).toBe(false); + + // Disable - FVC's disabled input is used, which reflects to native via FVC's template + act(() => fixture.componentInstance.model.control.disable()); + expect(fvc.disabled()).toBe(true); + expect(inputEl.disabled).toBe(true); // Set via FVC's template binding [disabled]="disabled()" + + // Enable - both should be enabled again + act(() => fixture.componentInstance.model.control.enable()); + expect(fvc.disabled()).toBe(false); + expect(inputEl.disabled).toBe(false); + }); + }); + + describe('error bindings', () => { + it('should sync NgModel control errors to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal(''); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + + // Add required validator programmatically + act(() => { + fixture.componentInstance.model.control.addValidators(Validators.required); + fixture.componentInstance.model.control.updateValueAndValidity(); + }); + + // Empty value should have required error + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('required'); + + // Valid value should clear errors + await actAsync(() => fixture.componentInstance.val.set('valid')); + expect(fvc.errors().length).toBe(0); + }); + + it('should sync multiple errors to FVC', async () => { + @Component({ + template: ``, + imports: [TemplateFvcInput, FormsModule], + }) + class TestCmp { + val = signal('ab'); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(TemplateFvcInput)).componentInstance; + + // Add validators programmatically + act(() => { + fixture.componentInstance.model.control.addValidators([ + Validators.required, + Validators.minLength(5), + ]); + fixture.componentInstance.model.control.updateValueAndValidity(); + }); + + // Should have minLength error (value is 'ab' which is less than 5) + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('minlength'); + expect(fvc.errors()[0].context).toEqual({requiredLength: 5, actualLength: 2}); + + // Empty value should have required error (minLength doesn't validate empty) + await actAsync(() => fixture.componentInstance.val.set('')); + expect(fvc.errors().length).toBe(1); + expect(fvc.errors()[0].kind).toBe('required'); + + // Valid value should clear all errors + await actAsync(() => fixture.componentInstance.val.set('valid')); + expect(fvc.errors().length).toBe(0); + }); + }); +}); + +describe('NgModel parseErrors binding', () => { + it('should sync FVC parseErrors to NgModel control', async () => { + @Component({ + selector: 'my-parsing-input', + template: '', + }) + class MyParsingInput { + readonly value = model(''); + constructor() { + const parseErrorsToken = inject(ɵFORM_FIELD_PARSE_ERRORS, {optional: true, self: true}); + if (parseErrorsToken) { + parseErrorsToken.set( + computed(() => { + return this.value() === 'INVALID' ? [{kind: 'parse', reason: 'cannot parse'}] : []; + }), + ); + } + } + } + + @Component({ + template: ``, + imports: [MyParsingInput, FormsModule], + }) + class TestCmp { + val = signal(''); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyParsingInput)).componentInstance; + + // Initially no errors + expect(fixture.componentInstance.model.control.errors).toBeNull(); + + // Set value that triggers parse error + act(() => fvc.value.set('INVALID')); + expect(fixture.componentInstance.model.control.errors).toEqual({ + parse: {kind: 'parse', reason: 'cannot parse'}, + }); + + // Clear parse error + act(() => fvc.value.set('valid')); + expect(fixture.componentInstance.model.control.errors).toBeNull(); + }); + + it('should merge parseErrors with validator errors', async () => { + @Component({ + selector: 'my-parsing-input', + template: '', + }) + class MyParsingInput { + readonly value = model(''); + constructor() { + const parseErrorsToken = inject(ɵFORM_FIELD_PARSE_ERRORS, {optional: true, self: true}); + if (parseErrorsToken) { + parseErrorsToken.set(computed(() => (this.value() === 'BAD' ? [{kind: 'parse'}] : []))); + } + } + } + + @Component({ + template: ``, + imports: [MyParsingInput, FormsModule], + }) + class TestCmp { + val = signal(''); + @ViewChild('model') model!: NgModel; + } + + const fixture = await actAsync(() => TestBed.createComponent(TestCmp)); + const fvc = fixture.debugElement.query(By.directive(MyParsingInput)).componentInstance; + + // Add minLength validator programmatically + act(() => { + fixture.componentInstance.model.control.addValidators(Validators.minLength(5)); + fixture.componentInstance.model.control.updateValueAndValidity(); + }); + + // Set value that triggers parse error (BAD = 3 chars, also minLength error) + act(() => fvc.value.set('BAD')); + expect(fixture.componentInstance.model.control.errors).toEqual({ + parse: {kind: 'parse'}, + minlength: {requiredLength: 5, actualLength: 3}, + }); + + // Set longer value - only minLength check (no parse error) + act(() => fvc.value.set('abc')); // 3 chars + expect(fixture.componentInstance.model.control.errors).toEqual({ + minlength: {requiredLength: 5, actualLength: 3}, + }); + + // Set valid long value - no errors + act(() => fvc.value.set('valid-long')); + expect(fixture.componentInstance.model.control.errors).toBeNull(); + }); +}); + +function act(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +} + +// Async version that waits for application to stabilize (for NgModel's async value updates) +async function actAsync(fn: () => T): Promise { + try { + return fn(); + } finally { + await TestBed.inject(ApplicationRef).whenStable(); + } +} diff --git a/packages/forms/src/directives/ng_model.ts b/packages/forms/src/directives/ng_model.ts index 4bcd105de35..d68dd018dce 100644 --- a/packages/forms/src/directives/ng_model.ts +++ b/packages/forms/src/directives/ng_model.ts @@ -24,7 +24,9 @@ import { Renderer2, Self, SimpleChanges, + type ɵControlDirectiveHost as ControlDirectiveHost, } from '@angular/core'; +import {Subscription} from 'rxjs'; import {FormHooks} from '../model/abstract_model'; import {FormControl} from '../model/form_control'; @@ -283,6 +285,32 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { this.formDirective?.removeControl(this); } + /** + * Internal control directive creation lifecycle hook. + * @internal + */ + ɵngControlCreate(host: ControlDirectiveHost): void { + super.ngControlCreate(host); + } + + /** + * Internal control directive update lifecycle hook. + * @internal + */ + ɵngControlUpdate(host: ControlDirectiveHost): void { + super.ngControlUpdate(host, false); + } + + /** + * Template-driven forms handle `required` via the `RequiredValidator` directive. + * + * This directive has a `required` input and a host binding to `[attr.required]`. It defines the + * source of truth for required-ness, so disable the normal control binding for it. + */ + protected override get shouldBindRequired() { + return false; + } + /** * @description * Returns an array that represents the path from the top-level form to this control. @@ -328,8 +356,13 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { } private _setUpStandalone(): void { - this.valueAccessor ??= this.selectedValueAccessor; - setUpControlValueAccessor(this.control, this, this.callSetDisabledState); + if (!this.isCustomControlBased) { + this.valueAccessor ??= this.selectedValueAccessor; + setUpControlValueAccessor(this.control, this, this.callSetDisabledState); + } else { + // FVC path - set up subscriptions for value/status sync + this.setupCustomControl(); + } this.control.updateValueAndValidity({emitEvent: false}); } @@ -339,8 +372,12 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { * @internal */ _setupWithForm(callSetDisabledState?: SetDisabledStateOption): void { - this.valueAccessor ??= this.selectedValueAccessor; - setUpControlValueAccessor(this.control, this, callSetDisabledState); + if (!this.isCustomControlBased) { + this.valueAccessor ??= this.selectedValueAccessor; + setUpControlValueAccessor(this.control, this, callSetDisabledState); + } else { + this.setupCustomControl(); + } } private _checkForErrors(): void { diff --git a/packages/forms/src/directives/reactive_directives/form_control_directive.ts b/packages/forms/src/directives/reactive_directives/form_control_directive.ts index d7cf7190a62..2316da89a37 100644 --- a/packages/forms/src/directives/reactive_directives/form_control_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_control_directive.ts @@ -7,6 +7,7 @@ */ import { + ChangeDetectorRef, Directive, EventEmitter, forwardRef, @@ -22,6 +23,7 @@ import { Renderer2, Self, SimpleChanges, + type ɵControlDirectiveHost as ControlDirectiveHost, } from '@angular/core'; import {FormControl} from '../../model/form_control'; @@ -155,9 +157,18 @@ export class FormControlDirective extends NgControl implements OnChanges, OnDest const previousForm = changes['form'].previousValue as FormControl | null; if (previousForm) { cleanUpControl(previousForm, this, /* validateControlPresenceOnChange */ false); + this.removeParseErrorsValidator(previousForm); + } + // Only set up CVA if not using FVC + if (!this.isCustomControlBased) { + // Now that we know we're not using FVC, select the value accessor + this.valueAccessor ??= this.selectedValueAccessor; + setUpControlValueAccessor(this.form, this, this.callSetDisabledState); + } else { + // Set up FVC subscriptions when form changes - mark for check so + // ɵngControlUpdate runs and syncs values/status to the FVC + this.setupCustomControl(); } - this.valueAccessor ??= this.selectedValueAccessor; - setUpControlValueAccessor(this.form, this, this.callSetDisabledState); this.form.updateValueAndValidity({emitEvent: false}); } if (isPropertyUpdated(changes, this.viewModel)) { @@ -207,4 +218,26 @@ export class FormControlDirective extends NgControl implements OnChanges, OnDest private _isControlChanged(changes: {[key: string]: any}): boolean { return changes.hasOwnProperty('form'); } + + /** + * Internal control directive creation lifecycle hook. + * + * The presence of this method tells the compiler to install `ɵɵControlFeature`, which will + * cause this directive to be recognized as a control directive by the `ɵcontrolCreate` and + * `ɵcontrol` instructions. + * + * @internal + */ + ɵngControlCreate(host: ControlDirectiveHost): void { + super.ngControlCreate(host); + } + + /** + * Internal control directive update lifecycle hook. + * + * @internal + */ + ɵngControlUpdate(host: ControlDirectiveHost): void { + super.ngControlUpdate(host, true); + } } diff --git a/packages/forms/src/directives/reactive_directives/form_control_name.ts b/packages/forms/src/directives/reactive_directives/form_control_name.ts index 1c7a627847e..19228702d9b 100644 --- a/packages/forms/src/directives/reactive_directives/form_control_name.ts +++ b/packages/forms/src/directives/reactive_directives/form_control_name.ts @@ -24,6 +24,7 @@ import { SimpleChanges, SkipSelf, ɵWritable as Writable, + type ɵControlDirectiveHost as ControlDirectiveHost, } from '@angular/core'; import {FormControl} from '../../model/form_control'; @@ -175,16 +176,18 @@ export class FormControlName extends NgControl implements OnChanges, OnDestroy { } /** - * Sets up the control with the form. - * - * Called by `AbstractFormDirective.addControl`. - * + * Sets up the control with the form, handling FVC vs CVA branching. + * Called by AbstractFormDirective.addControl. * @internal */ _setupWithForm(control: FormControl, callSetDisabledState?: SetDisabledStateOption): void { (this as Writable).control = control; - this.valueAccessor ??= this.selectedValueAccessor; - setUpControlValueAccessor(control, this, callSetDisabledState); + if (!this.isCustomControlBased) { + this.valueAccessor ??= this.selectedValueAccessor; + setUpControlValueAccessor(control, this, callSetDisabledState); + } else { + this.setupCustomControl(); + } } /** @docs-private */ @@ -239,6 +242,36 @@ export class FormControlName extends NgControl implements OnChanges, OnDestroy { (this as Writable).control = this.formDirective.addControl(this); this._added = true; } + + /** + * Internal control directive creation lifecycle hook. + * + * The presence of this method tells the compiler to install `ɵɵControlFeature`, which will + * cause this directive to be recognized as a control directive by the `ɵcontrolCreate` and + * `ɵcontrol` instructions. + * + * @internal + */ + ɵngControlCreate(host: ControlDirectiveHost): void { + super.ngControlCreate(host); + } + + /** + * Internal control directive update lifecycle hook. + * + * @internal + */ + ɵngControlUpdate(host: ControlDirectiveHost): void { + // Ensure behavior is the same if we're not using the custom control codepath. + if (!this.isCustomControlBased) { + return; + } + + // this.control is typically initialized by `ngOnChanges`, however `ɵngControlUpdate` fires + // first. So, we're responsible for initializing it here. + if (!this._added) this._setUpControl(); + super.ngControlUpdate(host, true); + } } function checkParentType(parent: ControlContainer | null, name: string | number | null) { diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index 7e2b0dad363..8cf11e76943 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -636,6 +636,31 @@ describe('reactive forms integration tests', () => { }); }); + describe('template pipeline integration', () => { + it('should not crash when a control directive is applied to an element inside an @if block', () => { + @Component({ + selector: 'my-app', + template: ` +
+ @if (true) { + + } +
+ `, + standalone: false, + }) + class App { + form = new FormGroup({name: new FormControl('Angular')}); + } + + const fixture = initTest(App); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')); + expect(input.nativeElement.value).toEqual('Angular'); + }); + }); + describe('form arrays', () => { it('should support form arrays', () => { const fixture = initTest(FormArrayComp);