fix(forms): align FormField CVA selection priority with standard forms

Prioritize custom ControlValueAccessor instances over default or built-in accessors when applying the [formField] directive. This is achieved by directly consuming selectValueAccessor from @angular/forms, ensuring absolute alignment with the precedence rules used across standard Angular form directives.
This commit is contained in:
Alex Rickabaugh
2026-04-03 12:51:43 -07:00
committed by Andrew Scott
parent 2e9aeea0fe
commit de56d74da3
4 changed files with 45 additions and 4 deletions
@@ -30,6 +30,7 @@ import {
NG_VALUE_ACCESSOR,
NgControl,
ɵFORM_FIELD_PARSE_ERRORS as FORM_FIELD_PARSE_ERRORS,
ɵselectValueAccessor as selectValueAccessor,
} from '@angular/forms';
import {type ValidationError} from '../api/rules';
import type {Field, FieldState} from '../api/types';
@@ -193,7 +194,18 @@ export class FormField<T> {
* @internal
*/
get controlValueAccessor(): ControlValueAccessor | undefined {
return this.controlValueAccessors?.[0] ?? this.interopNgControl?.valueAccessor ?? undefined;
if (!this.controlValueAccessors || this.controlValueAccessors.length === 0) {
return this.interopNgControl?.valueAccessor ?? undefined;
}
// Rely on the exact logic in `@angular/forms` to pick the accessors with correct priority,
// passing our fake `InteropNgControl` to fulfill its first parameter requirement.
return (
selectValueAccessor(
this.interopNgControl as unknown as NgControl,
this.controlValueAccessors,
) ?? undefined
);
}
/**
@@ -18,7 +18,13 @@ import {
viewChild,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
import {
ControlValueAccessor,
DefaultValueAccessor,
NG_VALUE_ACCESSOR,
NgControl,
ReactiveFormsModule,
} from '@angular/forms';
import {
debounce,
disabled,
@@ -373,6 +379,25 @@ describe('ControlValueAccessor', () => {
expect(() => fixture.componentInstance.disabled.set(true)).not.toThrowError(/NG0600/);
});
it('should pick custom CVA over default CVA when both are present', () => {
@Component({
selector: 'app-root',
// Import ReactiveFormsModule to provide the non-standalone DefaultValueAccessor directive.
// The selector for DefaultValueAccessor matches `[ngDefaultControl]`.
imports: [FormField, CustomControl, ReactiveFormsModule],
template: `<custom-control [formField]="f" ngDefaultControl />`,
})
class App {
f = form<string>(signal(''));
}
const fixture = act(() => TestBed.createComponent(App));
const customControlInstance = fixture.debugElement.children[0].injector.get(CustomControl);
act(() => fixture.componentInstance.f().value.set('updated'));
expect(customControlInstance.writeCount).toBe(2); // 1 initial + 1 update
});
describe('properties', () => {
describe('disabled', () => {
it('should bind to directive input', () => {
+1 -1
View File
@@ -393,7 +393,7 @@ export function syncPendingControls(
// TODO: vsavkin remove it once https://github.com/angular/angular/issues/3011 is implemented
export function selectValueAccessor(
dir: NgControl,
valueAccessors: ControlValueAccessor[] | null | undefined,
valueAccessors: readonly ControlValueAccessor[] | null | undefined,
): ControlValueAccessor | null {
if (!valueAccessors) return null;
+5 -1
View File
@@ -48,7 +48,11 @@ export {
SelectMultipleControlValueAccessor,
ɵNgSelectMultipleOption,
} from './directives/select_multiple_control_value_accessor';
export {SetDisabledStateOption, ɵFORM_FIELD_PARSE_ERRORS} from './directives/shared';
export {
selectValueAccessor as ɵselectValueAccessor,
SetDisabledStateOption,
ɵFORM_FIELD_PARSE_ERRORS,
} from './directives/shared';
export {
AsyncValidator,
AsyncValidatorFn,