diff --git a/packages/forms/signals/src/directive/form_field.ts b/packages/forms/signals/src/directive/form_field.ts index ff19089420b..4f7b5c0a5c6 100644 --- a/packages/forms/signals/src/directive/form_field.ts +++ b/packages/forms/signals/src/directive/form_field.ts @@ -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 { * @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 + ); } /** diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 169a5e189f6..1b09ef9c1d0 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -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: ``, + }) + class App { + f = form(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', () => { diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index 216d8c8f6eb..f37edab0e59 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -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; diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index 8ba65cae6e8..41d8bda1f52 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -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,