mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Matthew Beck
parent
9b9b0e93c9
commit
2e0cb52dbf
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user