Skip to content

Commit d89e522

Browse files
leonsenftthePunderWoman
authored andcommitted
fix(forms): debounce updates from interop controls
* Apply any debounce rules to updates from interop controls (if configured). * Add tests to ensure debouncing works for all control types (native, custom, and interop). (cherry picked from commit b1037ec)
1 parent 2bfd841 commit d89e522

File tree

3 files changed

+136
-6
lines changed

3 files changed

+136
-6
lines changed

packages/core/src/render3/instructions/control.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,7 @@ function listenToCustomControl(
271271
*/
272272
function listenToInteropControl(control: ɵControl<unknown>): void {
273273
const interopControl = control.ɵinteropControl!;
274-
interopControl.registerOnChange((value: unknown) => {
275-
const state = control.state();
276-
state.value.set(value);
277-
state.markAsDirty();
278-
});
274+
interopControl.registerOnChange((value: unknown) => control.state().setControlValue(value));
279275
interopControl.registerOnTouched(() => control.state().markAsTouched());
280276
}
281277

packages/forms/signals/test/web/field_directive.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '@angular/core';
2424
import {TestBed} from '@angular/core/testing';
2525
import {
26+
debounce,
2627
disabled,
2728
Field,
2829
form,
@@ -1969,6 +1970,67 @@ describe('field directive', () => {
19691970
.toEqual(Node.DOCUMENT_POSITION_PRECEDING);
19701971
});
19711972
});
1973+
1974+
describe('debounce', () => {
1975+
it('should support native control', async () => {
1976+
const {promise, resolve} = promiseWithResolvers<void>();
1977+
1978+
@Component({
1979+
imports: [Field],
1980+
template: `<input [field]="f" />`,
1981+
})
1982+
class TestCmp {
1983+
readonly f = form(signal(''), (p) => {
1984+
debounce(p, () => promise);
1985+
});
1986+
}
1987+
1988+
const fixture = act(() => TestBed.createComponent(TestCmp));
1989+
const input = fixture.nativeElement.querySelector('input');
1990+
1991+
act(() => {
1992+
input.value = 'typing';
1993+
input.dispatchEvent(new Event('input'));
1994+
});
1995+
expect(fixture.componentInstance.f().value()).toBe('');
1996+
1997+
resolve();
1998+
await promise;
1999+
expect(fixture.componentInstance.f().value()).toBe('typing');
2000+
});
2001+
2002+
it('should support custom control', async () => {
2003+
const {promise, resolve} = promiseWithResolvers<void>();
2004+
2005+
@Component({
2006+
selector: 'my-input',
2007+
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
2008+
})
2009+
class CustomInput implements FormValueControl<string> {
2010+
value = model('');
2011+
}
2012+
2013+
@Component({
2014+
imports: [Field, CustomInput],
2015+
template: `<my-input [field]="f" />`,
2016+
})
2017+
class TestCmp {
2018+
readonly f = form(signal(''), (p) => {
2019+
debounce(p, () => promise);
2020+
});
2021+
readonly customInput = viewChild.required(CustomInput);
2022+
}
2023+
2024+
const fixture = act(() => TestBed.createComponent(TestCmp));
2025+
2026+
act(() => fixture.componentInstance.customInput().value.set('typing'));
2027+
expect(fixture.componentInstance.f().value()).toBe('');
2028+
2029+
resolve();
2030+
await promise;
2031+
expect(fixture.componentInstance.f().value()).toBe('typing');
2032+
});
2033+
});
19722034
});
19732035

19742036
function setupRadioGroup() {
@@ -2005,3 +2067,25 @@ function act<T>(fn: () => T): T {
20052067
TestBed.tick();
20062068
}
20072069
}
2070+
2071+
/**
2072+
* Replace with `Promise.withResolvers()` once it's available.
2073+
*
2074+
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
2075+
*/
2076+
// TODO: share this with submit.spec.ts
2077+
function promiseWithResolvers<T = void>(): {
2078+
promise: Promise<T>;
2079+
resolve: (value: T | PromiseLike<T>) => void;
2080+
reject: (reason?: any) => void;
2081+
} {
2082+
let resolve!: (value: T | PromiseLike<T>) => void;
2083+
let reject!: (reason?: any) => void;
2084+
2085+
const promise = new Promise<T>((res, rej) => {
2086+
resolve = res;
2087+
reject = rej;
2088+
});
2089+
2090+
return {promise, resolve, reject};
2091+
}

packages/forms/signals/test/web/interop.spec.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {Component, inject, signal, provideZonelessChangeDetection, viewChild} from '@angular/core';
1010
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
11-
import {disabled, Field, form} from '@angular/forms/signals';
11+
import {debounce, disabled, Field, form} from '@angular/forms/signals';
1212
import {TestBed} from '@angular/core/testing';
1313

1414
describe('ControlValueAccessor', () => {
@@ -92,6 +92,34 @@ describe('ControlValueAccessor', () => {
9292
expect(fixture.componentInstance.f().value()).toBe('typing');
9393
});
9494

95+
it('should support debounce', async () => {
96+
const {promise, resolve} = promiseWithResolvers<void>();
97+
98+
@Component({
99+
imports: [CustomControl, Field],
100+
template: `<custom-control [field]="f" />`,
101+
})
102+
class TestCmp {
103+
readonly f = form(signal(''), (p) => {
104+
debounce(p, () => promise);
105+
});
106+
readonly control = viewChild.required(CustomControl);
107+
}
108+
109+
const fixture = act(() => TestBed.createComponent(TestCmp));
110+
const input = fixture.nativeElement.querySelector('input');
111+
112+
act(() => {
113+
input.value = 'typing';
114+
input.dispatchEvent(new Event('input'));
115+
});
116+
expect(fixture.componentInstance.f().value()).toBe('');
117+
118+
resolve();
119+
await promise;
120+
expect(fixture.componentInstance.f().value()).toBe('typing');
121+
});
122+
95123
it('should mark field dirty on changes', () => {
96124
@Component({
97125
imports: [Field, CustomControl],
@@ -286,3 +314,25 @@ function act<T>(fn: () => T): T {
286314
TestBed.tick();
287315
}
288316
}
317+
318+
/**
319+
* Replace with `Promise.withResolvers()` once it's available.
320+
*
321+
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
322+
*/
323+
// TODO: share this with submit.spec.ts
324+
function promiseWithResolvers<T = void>(): {
325+
promise: Promise<T>;
326+
resolve: (value: T | PromiseLike<T>) => void;
327+
reject: (reason?: any) => void;
328+
} {
329+
let resolve!: (value: T | PromiseLike<T>) => void;
330+
let reject!: (reason?: any) => void;
331+
332+
const promise = new Promise<T>((res, rej) => {
333+
resolve = res;
334+
reject = rej;
335+
});
336+
337+
return {promise, resolve, reject};
338+
}

0 commit comments

Comments
 (0)