From 73691e46418c610de04bbc4bac0094446d35c0f9 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Mon, 30 Mar 2026 10:10:49 -0700 Subject: [PATCH 1/3] fix(forms): use controlValue in NgControl for CVA interop use controlValue() instead of value() to ensure that CVA controls see the most recent user input immediately rather than waiting for it to be synchronized after debouncing --- .../src/controls/interop_ng_control.ts | 3 +- .../forms/signals/test/web/interop.spec.ts | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/forms/signals/src/controls/interop_ng_control.ts b/packages/forms/signals/src/controls/interop_ng_control.ts index 5771fd596820..e3e0a6efbaa9 100644 --- a/packages/forms/signals/src/controls/interop_ng_control.ts +++ b/packages/forms/signals/src/controls/interop_ng_control.ts @@ -66,7 +66,8 @@ export class InteropNgControl implements CombinedControl { readonly control: AbstractControl = this as unknown as AbstractControl; get value(): any { - return this.field().value(); + // CVA controls are not aware of user debouncing and will expect `NgControl.value` to reflect their latest writes. + return this.field().controlValue(); } get valid(): boolean { diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 1b09ef9c1d01..c5e5879a2555 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -398,6 +398,68 @@ describe('ControlValueAccessor', () => { expect(customControlInstance.writeCount).toBe(2); // 1 initial + 1 update }); + it('should reflect latest written value in NgControl.value when debounce is active', () => { + let ngControlValueInsideCva: any = null; + + @Component({ + selector: 'custom-control-with-debounce', + template: ``, + }) + class CustomControlWithDebounce implements ControlValueAccessor { + ngControl = inject(NgControl); + value = ''; + private onChangeFn?: (value: string) => void; + + constructor() { + this.ngControl.valueAccessor = this; + } + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(newValue); + ngControlValueInsideCva = this.ngControl.value; + } + } + + @Component({ + imports: [CustomControlWithDebounce, FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('initial'), (p) => { + debounce(p, 'blur'); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().value()).toBe('initial'); + + const debugEl = fixture.debugElement.query( + (el) => el.componentInstance instanceof CustomControlWithDebounce, + ); + const cvaInstance = debugEl.componentInstance; + + act(() => cvaInstance.onInput('updated')); + + // NgControl.value should be 'updated' inside onInput! + expect(ngControlValueInsideCva).toBe('updated'); + + // But the field value should still be 'initial' if it haven't been flushed yet! + expect(field().value()).toBe('initial'); + }); + describe('properties', () => { describe('disabled', () => { it('should bind to directive input', () => { From 472136e6cfedf6343f0063fb58c55a0039f4483e Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 27 Mar 2026 14:42:22 -0700 Subject: [PATCH 2/3] feat(forms): shim legacy NG_VALIDATORS into parseErrors for CVA mode - Injected `NG_VALIDATORS` into `FormField` and exposed it via an internal getter. - Created a `computed` signal in `cvaControlCreate` to run legacy validators and map into standard validation errors without generic `as any` type assertions. - Intercepted `registerOnValidatorChange` to trigger updates even when the model value remains unchanged (e.g., going from `null` to `null`). - Added integration tests to verify parse error propagation and reactivity. --- .../forms/signals/compat/index.api.md | 3 - goldens/public-api/forms/signals/index.api.md | 5 - .../signals/src/directive/control_cva.ts | 43 +++++- .../forms/signals/src/directive/form_field.ts | 11 +- .../forms/signals/test/web/interop.spec.ts | 140 ++++++++++++++++++ 5 files changed, 191 insertions(+), 11 deletions(-) diff --git a/goldens/public-api/forms/signals/compat/index.api.md b/goldens/public-api/forms/signals/compat/index.api.md index 77222616858d..fc3c5bbd257b 100644 --- a/goldens/public-api/forms/signals/compat/index.api.md +++ b/goldens/public-api/forms/signals/compat/index.api.md @@ -5,15 +5,12 @@ ```ts import { AbstractControl } from '@angular/forms'; -import { ControlValueAccessor } from '@angular/forms'; import { EventEmitter } from '@angular/core'; import { FormControlState } from '@angular/forms'; import { FormControlStatus } from '@angular/forms'; import * as i0 from '@angular/core'; import { Injector } from '@angular/core'; import { Signal } from '@angular/core'; -import { ValidationErrors } from '@angular/forms'; -import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; // @public diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 019e94e907ed..ff0a6701c970 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -5,9 +5,7 @@ ```ts import { AbstractControl } from '@angular/forms'; -import { ControlValueAccessor } from '@angular/forms'; import { DebounceTimer } from '@angular/core'; -import { FormControlStatus } from '@angular/forms'; import { HttpResourceOptions } from '@angular/common/http'; import { HttpResourceRequest } from '@angular/common/http'; import * as i0 from '@angular/core'; @@ -21,8 +19,6 @@ import { Provider } from '@angular/core'; import { ResourceRef } from '@angular/core'; import { Signal } from '@angular/core'; import { StandardSchemaV1 } from '@standard-schema/spec'; -import { ValidationErrors } from '@angular/forms'; -import { ValidatorFn } from '@angular/forms'; import { WritableSignal } from '@angular/core'; // @public @@ -175,7 +171,6 @@ export class FormField { readonly field: i0.InputSignal>; focus(options?: FocusOptions): void; readonly injector: Injector; - protected get interopNgControl(): InteropNgControl; registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; readonly state: Signal>; // (undocumented) diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index 13ecdb970fc2..d064fc68b60d 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -6,7 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {untracked, type ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core'; +import { + computed, + signal, + untracked, + type WritableSignal, + type ɵControlDirectiveHost as ControlDirectiveHost, +} from '@angular/core'; +import {NG_VALIDATORS, type Validator, Validators, type ValidatorFn} from '@angular/forms'; +import {reactiveErrorsToSignalErrors} from '../compat/validation_errors'; +import {type ValidationError} from '../api/rules'; import { bindingUpdated, CONTROL_BINDING_NAMES, @@ -17,6 +26,10 @@ import { import {setNativeDomProperty} from './native'; import type {FormField} from './form_field'; +function isValidatorObject(v: Function | Validator): v is Validator { + return typeof v === 'object' && v !== null; +} + export function cvaControlCreate( host: ControlDirectiveHost, parent: FormField, @@ -31,6 +44,34 @@ export function cvaControlCreate( parent.state().controlValue.set(value as any); }); parent.controlValueAccessor!.registerOnTouched(() => parent.state().markAsTouched()); + + const legacyValidators = parent.injector.get(NG_VALIDATORS, null, {optional: true, self: true}); + if (legacyValidators) { + let version: WritableSignal | undefined; + + for (const v of legacyValidators) { + if (isValidatorObject(v) && v.registerOnValidatorChange) { + version ??= signal(0); + v.registerOnValidatorChange(() => { + version!.update((n) => n + 1); + }); + } + } + + const validatorFns = legacyValidators.map((v) => + typeof v === 'function' ? (v as ValidatorFn) : v.validate.bind(v), + ); + const mergedValidator = Validators.compose(validatorFns); + + const parseErrors = computed(() => { + // Read the `version` signal to re-run the validator when legacy validators trigger their change callbacks. + version?.(); + const errors = mergedValidator ? mergedValidator(parent.interopNgControl.control) : null; + return reactiveErrorsToSignalErrors(errors, parent.interopNgControl.control); + }); + parent.parseErrorsSource.set(parseErrors as any); + } + parent.registerAsBinding(); return () => { diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index 4f7b5c0a5c6c..c631d69e6ea8 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -27,8 +27,11 @@ import { } from '@angular/core'; import { type ControlValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, + type Validator, + type ValidatorFn, ɵFORM_FIELD_PARSE_ERRORS as FORM_FIELD_PARSE_ERRORS, ɵselectValueAccessor as selectValueAccessor, } from '@angular/forms'; @@ -153,10 +156,13 @@ export class FormField { /** Any `ControlValueAccessor` instances provided on the host element. */ private readonly controlValueAccessors = inject(NG_VALUE_ACCESSOR, {optional: true, self: true}); + /** Any `Validator` or `ValidatorFn` instances provided on the host element. */ + private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true}); private readonly validityMonitor = inject(InputValidityMonitor); - private readonly parseErrorsSource = signal< + /** @internal */ + readonly parseErrorsSource = signal< Signal | undefined >(undefined); @@ -164,7 +170,8 @@ export class FormField { private _interopNgControl: InteropNgControl | undefined; /** Lazily instantiates a fake `NgControl` for this form field. */ - protected get interopNgControl(): InteropNgControl { + /** @internal */ + get interopNgControl(): InteropNgControl { return (this._interopNgControl ??= new InteropNgControl(this.state)); } diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index c5e5879a2555..fe7f14d2e687 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -10,6 +10,7 @@ import { ChangeDetectionStrategy, Component, Directive, + forwardRef, inject, input, provideZonelessChangeDetection, @@ -19,11 +20,15 @@ import { } from '@angular/core'; import {TestBed} from '@angular/core/testing'; import { + AbstractControl, ControlValueAccessor, DefaultValueAccessor, + NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule, + ValidationErrors, + Validator, } from '@angular/forms'; import { debounce, @@ -165,6 +170,141 @@ describe('ControlValueAccessor', () => { expect(control.writeCount).toBe(1); // Should still be 1 (No write-back!) }); + it('propagates parse errors from legacy NG_VALIDATORS to field', () => { + const legacyErrors = signal(null); + + @Component({ + selector: 'legacy-control-with-validators', + template: ``, + providers: [ + {provide: NG_VALUE_ACCESSOR, useExisting: LegacyControlWithValidators, multi: true}, + {provide: NG_VALIDATORS, useExisting: LegacyControlWithValidators, multi: true}, + ], + }) + class LegacyControlWithValidators implements ControlValueAccessor, Validator { + value = ''; + + private onChangeFn?: (value: string) => void; + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + validate(control: AbstractControl): ValidationErrors | null { + return legacyErrors(); + } + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(newValue); + } + } + + @Component({ + imports: [LegacyControlWithValidators, FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('test')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().errors()).toEqual([]); + + act(() => legacyErrors.set({'legacy-parse': {text: 'bad'}})); + expect(field().errors()).toEqual([ + jasmine.objectContaining({ + kind: 'legacy-parse', + context: {text: 'bad'}, + }) as any, + ]); + }); + + it('should re-evaluate parse errors when registerOnValidatorChange is called', () => { + const legacyErrors = signal(null); + let validatorComponent: any = null; + + @Component({ + selector: 'legacy-control-with-on-validator-change', + template: ``, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LegacyControlWithOnValidatorChange), + multi: true, + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => LegacyControlWithOnValidatorChange), + multi: true, + }, + ], + }) + class LegacyControlWithOnValidatorChange implements ControlValueAccessor, Validator { + value = ''; + private onChangeFn?: (value: string | null) => void; + validatorOnChange?: () => void; + + constructor() { + validatorComponent = this; + } + + writeValue(newValue: string): void { + this.value = newValue; + } + + registerOnChange(fn: (value: string | null) => void): void { + this.onChangeFn = fn; + } + + registerOnTouched(fn: () => void): void {} + + validate(control: AbstractControl): ValidationErrors | null { + return legacyErrors(); + } + + registerOnValidatorChange(fn: () => void): void { + this.validatorOnChange = fn; + } + + onInput(newValue: string) { + this.value = newValue; + this.onChangeFn?.(null); // Keep value null + } + } + + @Component({ + imports: [LegacyControlWithOnValidatorChange, FormField], + template: ``, + }) + class TestCmp { + f = form(signal(null)); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const field = fixture.componentInstance.f; + + expect(field().errors()).toEqual([]); + + act(() => legacyErrors.set({'legacy-parse': {text: 'bad'}})); + act(() => validatorComponent.validatorOnChange?.()); // Force recalculation! + + expect(field().errors()).toEqual([ + jasmine.objectContaining({ + kind: 'legacy-parse', + context: {text: 'bad'}, + }) as any, + ]); + }); + it('should support debounce', async () => { const {promise, resolve} = promiseWithResolvers(); From ca4a428837e99cc852e87d502d93828487206b5e Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 2 Apr 2026 15:40:03 -0700 Subject: [PATCH 3/3] fixup! feat(forms): shim legacy NG_VALIDATORS into parseErrors for CVA mode --- packages/forms/signals/src/directive/control_cva.ts | 8 +++++++- packages/forms/signals/src/directive/form_field.ts | 4 ---- packages/forms/signals/test/web/interop.spec.ts | 10 +++++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index d064fc68b60d..8d9cff89cfac 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -10,6 +10,7 @@ import { computed, signal, untracked, + type Signal, type WritableSignal, type ɵControlDirectiveHost as ControlDirectiveHost, } from '@angular/core'; @@ -69,7 +70,12 @@ export function cvaControlCreate( const errors = mergedValidator ? mergedValidator(parent.interopNgControl.control) : null; return reactiveErrorsToSignalErrors(errors, parent.interopNgControl.control); }); - parent.parseErrorsSource.set(parseErrors as any); + // We must cast here because `CompatValidationError` claims to have `fieldTree` statically (to + // satisfy `ValidationState` elsewhere), but at construction it is created without it and acts as + // `WithoutFieldTree` initially. + parent.parseErrorsSource.set( + parseErrors as unknown as Signal, + ); } parent.registerAsBinding(); diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index c631d69e6ea8..6721269ac6d6 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -30,8 +30,6 @@ import { NG_VALIDATORS, NG_VALUE_ACCESSOR, NgControl, - type Validator, - type ValidatorFn, ɵFORM_FIELD_PARSE_ERRORS as FORM_FIELD_PARSE_ERRORS, ɵselectValueAccessor as selectValueAccessor, } from '@angular/forms'; @@ -156,8 +154,6 @@ export class FormField { /** Any `ControlValueAccessor` instances provided on the host element. */ private readonly controlValueAccessors = inject(NG_VALUE_ACCESSOR, {optional: true, self: true}); - /** Any `Validator` or `ValidatorFn` instances provided on the host element. */ - private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true}); private readonly validityMonitor = inject(InputValidityMonitor); diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index fe7f14d2e687..4166738f80b7 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -224,7 +224,7 @@ describe('ControlValueAccessor', () => { jasmine.objectContaining({ kind: 'legacy-parse', context: {text: 'bad'}, - }) as any, + }), ]); }); @@ -301,7 +301,7 @@ describe('ControlValueAccessor', () => { jasmine.objectContaining({ kind: 'legacy-parse', context: {text: 'bad'}, - }) as any, + }), ]); }); @@ -539,7 +539,7 @@ describe('ControlValueAccessor', () => { }); it('should reflect latest written value in NgControl.value when debounce is active', () => { - let ngControlValueInsideCva: any = null; + let ngControlValueInsideCva: string | null = null; @Component({ selector: 'custom-control-with-debounce', @@ -594,9 +594,9 @@ describe('ControlValueAccessor', () => { act(() => cvaInstance.onInput('updated')); // NgControl.value should be 'updated' inside onInput! - expect(ngControlValueInsideCva).toBe('updated'); + expect(ngControlValueInsideCva as unknown as string).toBe('updated'); - // But the field value should still be 'initial' if it haven't been flushed yet! + // But the field value should still be 'initial' if it hasn't been flushed yet! expect(field().value()).toBe('initial'); });