mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
fix(forms): deduplicate writeValue calls in CVA interop
Update bindings['controlValue'] during onChange to track the view value. This allows bindingUpdated to skip writeValue if the model value matches the last seen view value, preventing redundant writes. Fixes #67847
This commit is contained in:
parent
a0aa8304cd
commit
2e9aeea0fe
2 changed files with 37 additions and 4 deletions
|
|
@ -21,16 +21,22 @@ export function cvaControlCreate(
|
|||
host: ControlDirectiveHost,
|
||||
parent: FormField<unknown>,
|
||||
): () => void {
|
||||
parent.controlValueAccessor!.registerOnChange((value: unknown) =>
|
||||
parent.state().controlValue.set(value as any),
|
||||
);
|
||||
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
|
||||
|
||||
parent.controlValueAccessor!.registerOnChange((value: unknown) => {
|
||||
// Update tracking for 'controlValue' here so that when the effect runs,
|
||||
// `bindingUpdated` sees that the model value matches the last seen view value.
|
||||
// This prevents the framework from writing the same value back to the CVA (CVA loopback).
|
||||
bindings['controlValue'] = value;
|
||||
parent.state().controlValue.set(value as any);
|
||||
});
|
||||
parent.controlValueAccessor!.registerOnTouched(() => parent.state().markAsTouched());
|
||||
parent.registerAsBinding();
|
||||
|
||||
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
|
||||
return () => {
|
||||
const fieldState = parent.state();
|
||||
const value = fieldState.value();
|
||||
|
||||
if (bindingUpdated(bindings, 'controlValue', value)) {
|
||||
// We don't know if the interop control has underlying signals, so we must use `untracked` to
|
||||
// prevent writing to a signal in a reactive context.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ describe('ControlValueAccessor', () => {
|
|||
})
|
||||
class CustomControl implements ControlValueAccessor {
|
||||
value = '';
|
||||
writeCount = 0;
|
||||
disabled = false;
|
||||
|
||||
private onChangeFn?: (value: string) => void;
|
||||
|
|
@ -68,6 +69,7 @@ describe('ControlValueAccessor', () => {
|
|||
|
||||
writeValue(newValue: string): void {
|
||||
this.value = newValue;
|
||||
this.writeCount++;
|
||||
}
|
||||
|
||||
registerOnChange(fn: (value: string) => void): void {
|
||||
|
|
@ -132,6 +134,31 @@ describe('ControlValueAccessor', () => {
|
|||
expect(fixture.componentInstance.f().value()).toBe('typing');
|
||||
});
|
||||
|
||||
it('should not write back to CVA on view change', () => {
|
||||
@Component({
|
||||
imports: [CustomControl, FormField],
|
||||
template: `<custom-control [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal('test'));
|
||||
readonly control = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const control = fixture.componentInstance.control();
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
|
||||
expect(control.writeCount).toBe(1); // Initial write
|
||||
|
||||
act(() => {
|
||||
input.value = 'typing';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.f().value()).toBe('typing');
|
||||
expect(control.writeCount).toBe(1); // Should still be 1 (No write-back!)
|
||||
});
|
||||
|
||||
it('should support debounce', async () => {
|
||||
const {promise, resolve} = promiseWithResolvers<void>();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue