Skip to content

Commit 24e52d4

Browse files
alxhubleonsenft
authored andcommitted
feat(forms): add debounce option to validateAsync and validateHttp
This adds support for a `debounce` option to the `validateAsync` and `validateHttp` functions. This allows developers to debounce the triggering of async validators to improve performance. A `DebounceTimer` type was also added to `@angular/core` to represent the wait condition parameters uniformly.
1 parent b5af15a commit 24e52d4

File tree

7 files changed

+80
-8
lines changed

7 files changed

+80
-8
lines changed

goldens/public-api/core/index.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,14 +499,17 @@ export const CSP_NONCE: InjectionToken<string | null>;
499499
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;
500500

501501
// @public
502-
export function debounced<T>(source: () => T, wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
502+
export function debounced<T>(source: () => T, wait: NoInfer<DebounceTimer<T>>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
503503

504504
// @public
505505
export interface DebouncedOptions<T> {
506506
equal?: ValueEqualityFn<T>;
507507
injector?: Injector;
508508
}
509509

510+
// @public
511+
export type DebounceTimer<T> = number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);
512+
510513
// @public (undocumented)
511514
export class DebugElement extends DebugNode {
512515
constructor(nativeNode: Element);

goldens/public-api/forms/signals/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { AbstractControl } from '@angular/forms';
88
import { ControlValueAccessor } from '@angular/forms';
9+
import { DebounceTimer } from '@angular/core';
910
import { FormControlStatus } from '@angular/forms';
1011
import { HttpResourceOptions } from '@angular/common/http';
1112
import { HttpResourceRequest } from '@angular/common/http';
@@ -47,6 +48,7 @@ export type AsyncValidationResult<E extends ValidationError = ValidationError> =
4748

4849
// @public
4950
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
51+
readonly debounce?: DebounceTimer<TParams | undefined>;
5052
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
5153
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
5254
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
@@ -260,6 +262,7 @@ export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path:
260262

261263
// @public
262264
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
265+
readonly debounce?: DebounceTimer<string | HttpResourceRequest | undefined>;
263266
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
264267
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
265268
readonly options?: HttpResourceOptions<TResult, unknown>;

packages/core/src/resource/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,11 @@ export interface DebouncedOptions<T> {
305305
/** The equality function to use for comparing values. */
306306
equal?: ValueEqualityFn<T>;
307307
}
308+
309+
/**
310+
* Represents the wait condition for item debouncing.
311+
* Can be a number of milliseconds or a function that returns a Promise.
312+
*/
313+
export type DebounceTimer<T> =
314+
| number
315+
| ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);

packages/core/src/resource/debounce.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {effect} from '../render3/reactivity/effect';
1212
import {linkedSignal} from '../render3/reactivity/linked_signal';
1313
import {signal} from '../render3/reactivity/signal';
1414
import {untracked} from '../render3/reactivity/untracked';
15-
import {Resource, ResourceSnapshot, type DebouncedOptions} from './api';
15+
import {Resource, ResourceSnapshot, type DebounceTimer, type DebouncedOptions} from './api';
1616
import {resourceFromSnapshots} from './from_snapshots';
1717
import {
1818
invalidResourceCreationInParams,
@@ -33,7 +33,7 @@ import {
3333
*/
3434
export function debounced<T>(
3535
source: () => T,
36-
wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>,
36+
wait: NoInfer<DebounceTimer<T>>,
3737
options?: NoInfer<DebouncedOptions<T>>,
3838
): Resource<T> {
3939
if (isInParamsFunction()) {

packages/forms/signals/src/api/rules/validation/validate_async.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ResourceRef, Signal} from '@angular/core';
9+
import {DebounceTimer, ResourceRef, ResourceSnapshot, Signal, debounced} from '@angular/core';
1010
import {FieldNode} from '../../../field/node';
1111
import {addDefaultField} from '../../../field/validation';
1212
import {FieldPathNode} from '../../../schema/path_node';
@@ -67,6 +67,12 @@ export interface AsyncValidatorOptions<
6767
*/
6868
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
6969

70+
/**
71+
* Duration in milliseconds to wait before triggering the async operation, or a function that
72+
* returns a promise that resolves when the update should proceed.
73+
*/
74+
readonly debounce?: DebounceTimer<TParams | undefined>;
75+
7076
/**
7177
* A function that receives the resource params and returns a resource of the given params.
7278
* The given params should be used as is to create the resource.
@@ -118,7 +124,13 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
118124
const pathNode = FieldPathNode.unwrapFieldPath(path);
119125

120126
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
121-
(_state, params) => opts.factory(params),
127+
(_state, params) => {
128+
if (opts.debounce !== undefined) {
129+
const debouncedResource = debounced(() => params(), opts.debounce);
130+
return opts.factory(debouncedResource.value);
131+
}
132+
return opts.factory(params);
133+
},
122134
);
123135
RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true;
124136

packages/forms/signals/src/api/rules/validation/validate_http.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
10-
import {Signal} from '@angular/core';
10+
import {DebounceTimer, ResourceSnapshot, Signal} from '@angular/core';
1111
import {
1212
FieldContext,
1313
SchemaPath,
@@ -62,6 +62,12 @@ export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKin
6262
* The options to use when creating the httpResource.
6363
*/
6464
readonly options?: HttpResourceOptions<TResult, unknown>;
65+
66+
/**
67+
* Duration in milliseconds to wait before triggering the async operation, or a function that
68+
* returns a promise that resolves when the update should proceed.
69+
*/
70+
readonly debounce?: DebounceTimer<string | HttpResourceRequest | undefined>;
6571
}
6672

6773
/**
@@ -83,7 +89,10 @@ export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKi
8389
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
8490
) {
8591
validateAsync(path, {
86-
params: opts.request,
92+
params: opts.request as (
93+
ctx: FieldContext<TValue, TPathKind>,
94+
) => string | HttpResourceRequest | undefined,
95+
debounce: opts.debounce,
8796
factory: (request: Signal<any>) => httpResource(request, opts.options),
8897
onSuccess: opts.onSuccess,
8998
onError: opts.onError,

packages/forms/signals/test/node/resource.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
type Signal,
1717
} from '@angular/core';
1818
import {TestBed} from '@angular/core/testing';
19-
import {isNode} from '@angular/private/testing';
19+
import {isNode, timeout, useAutoTick} from '@angular/private/testing';
2020

2121
import {
2222
applyEach,
@@ -43,6 +43,8 @@ interface Address {
4343
}
4444

4545
describe('resources', () => {
46+
useAutoTick();
47+
4648
let appRef: ApplicationRef;
4749
let backend: HttpTestingController;
4850
let injector: Injector;
@@ -404,6 +406,41 @@ describe('resources', () => {
404406
expect(f().metadata(RES)).toBe(undefined);
405407
});
406408

409+
it('should support debounce in validateHttp', async () => {
410+
const usernameForm = form(
411+
signal('unique-user'),
412+
(p) => {
413+
validateHttp(p, {
414+
request: ({value}) => `/api/check?username=${value()}`,
415+
debounce: 50, // Short debounce
416+
onSuccess: (available: boolean) => (available ? undefined : {kind: 'username-taken'}),
417+
onError: () => null,
418+
});
419+
},
420+
{injector},
421+
);
422+
423+
TestBed.tick();
424+
const req1 = backend.expectOne('/api/check?username=unique-user');
425+
req1.flush(true);
426+
await appRef.whenStable();
427+
expect(usernameForm().valid()).toBe(true);
428+
usernameForm().value.set('taken-user');
429+
TestBed.tick();
430+
431+
// Should not have triggered a new request yet
432+
backend.expectNone('/api/check?username=taken-user');
433+
434+
// Wait for debounce
435+
await timeout(80);
436+
TestBed.tick();
437+
const req2 = backend.expectOne('/api/check?username=taken-user');
438+
req2.flush(false);
439+
await appRef.whenStable();
440+
441+
expect(usernameForm().valid()).toBe(false);
442+
});
443+
407444
describe('reloadValidation', () => {
408445
it('should trigger a reload of async http validation', async () => {
409446
const usernameForm = form(

0 commit comments

Comments
 (0)