From 2e0cb52dbf0f519ffc5c99b95e5f4b965fa332a7 Mon Sep 17 00:00:00 2001 From: Bhuvansh855 Date: Fri, 19 Jun 2026 12:38:49 +0530 Subject: [PATCH] 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. --- .../signals/src/directive/control_cva.ts | 6 +- .../forms/signals/test/web/interop.spec.ts | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/forms/signals/src/directive/control_cva.ts b/packages/forms/signals/src/directive/control_cva.ts index 38f95eb4632..5857d68452f 100644 --- a/packages/forms/signals/src/directive/control_cva.ts +++ b/packages/forms/signals/src/directive/control_cva.ts @@ -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) { diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 45328911030..c2789874040 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -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: ``, + }) + 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: ``, + }) + 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', () => {