fix(forms): prevent stale CVA writeback during debounce

Use controlValue() instead of value() when synchronizing
ControlValueAccessor instances.

When debounce is active, value() can still contain the
previous model value while controlValue() reflects the
latest user-entered value. This prevents stale values
from being written back to the CVA before the debounce
is flushed.

Adds a regression test covering the debounce scenario.
This commit is contained in:
Bhuvansh855
2026-06-19 12:38:49 +05:30
committed by Matthew Beck
parent 9b9b0e93c9
commit 2e0cb52dbf
2 changed files with 59 additions and 3 deletions
@@ -88,12 +88,12 @@ export function cvaControlCreate(
return () => {
const fieldState = parent.state();
const value = fieldState.value();
const controlValue = fieldState.controlValue();
if (bindingUpdated(bindings, 'controlValue', value)) {
if (bindingUpdated(bindings, 'controlValue', controlValue)) {
// 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.
untracked(() => parent.controlValueAccessor!.writeValue(value));
untracked(() => parent.controlValueAccessor!.writeValue(controlValue));
}
for (const name of CONTROL_BINDING_NAMES) {
@@ -607,6 +607,62 @@ describe('ControlValueAccessor', () => {
expect(field().value()).toBe('initial');
});
it('should not write stale model values back to a CVA while debounce is pending', () => {
let writeValues: string[] = [];
@Component({
selector: 'custom-control-writeback-test',
template: `<input [value]="value" (input)="onInput($event.target.value)" />`,
})
class CustomControlWritebackTest implements ControlValueAccessor {
value = '';
private onChangeFn?: (value: string) => void;
writeValue(newValue: string): void {
writeValues.push(newValue);
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);
}
}
@Component({
imports: [CustomControlWritebackTest, FormField],
template: `<custom-control-writeback-test [formField]="f" />`,
})
class TestCmp {
readonly f = form(signal('initial'), (p) => {
debounce(p, 'blur');
});
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const debugEl = fixture.debugElement.query(
(el) => el.componentInstance instanceof CustomControlWritebackTest,
);
const cvaInstance = debugEl.componentInstance as CustomControlWritebackTest;
writeValues = [];
act(() => cvaInstance.onInput('updated'));
expect(cvaInstance.value).toBe('updated');
expect(writeValues).toEqual([]);
});
describe('properties', () => {
describe('disabled', () => {
it('should bind to directive input', () => {