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.
This commit is contained in:
Alex Rickabaugh
2026-03-23 15:12:32 -07:00
committed by Leon Senft
parent b5af15a332
commit 24e52d450d
7 changed files with 80 additions and 8 deletions
+4 -1
View File
@@ -499,7 +499,7 @@ export const CSP_NONCE: InjectionToken<string | null>;
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;
// @public
export function debounced<T>(source: () => T, wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
export function debounced<T>(source: () => T, wait: NoInfer<DebounceTimer<T>>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
// @public
export interface DebouncedOptions<T> {
@@ -507,6 +507,9 @@ export interface DebouncedOptions<T> {
injector?: Injector;
}
// @public
export type DebounceTimer<T> = number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);
// @public (undocumented)
export class DebugElement extends DebugNode {
constructor(nativeNode: Element);
@@ -6,6 +6,7 @@
import { AbstractControl } from '@angular/forms';
import { ControlValueAccessor } from '@angular/forms';
import { DebounceTimer } from '@angular/core';
import { FormControlStatus } from '@angular/forms';
import { HttpResourceOptions } from '@angular/common/http';
import { HttpResourceRequest } from '@angular/common/http';
@@ -47,6 +48,7 @@ export type AsyncValidationResult<E extends ValidationError = ValidationError> =
// @public
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly debounce?: DebounceTimer<TParams | undefined>;
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
@@ -260,6 +262,7 @@ export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path:
// @public
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly debounce?: DebounceTimer<string | HttpResourceRequest | undefined>;
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly options?: HttpResourceOptions<TResult, unknown>;
+8
View File
@@ -305,3 +305,11 @@ export interface DebouncedOptions<T> {
/** The equality function to use for comparing values. */
equal?: ValueEqualityFn<T>;
}
/**
* Represents the wait condition for item debouncing.
* Can be a number of milliseconds or a function that returns a Promise.
*/
export type DebounceTimer<T> =
| number
| ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);
+2 -2
View File
@@ -12,7 +12,7 @@ import {effect} from '../render3/reactivity/effect';
import {linkedSignal} from '../render3/reactivity/linked_signal';
import {signal} from '../render3/reactivity/signal';
import {untracked} from '../render3/reactivity/untracked';
import {Resource, ResourceSnapshot, type DebouncedOptions} from './api';
import {Resource, ResourceSnapshot, type DebounceTimer, type DebouncedOptions} from './api';
import {resourceFromSnapshots} from './from_snapshots';
import {
invalidResourceCreationInParams,
@@ -33,7 +33,7 @@ import {
*/
export function debounced<T>(
source: () => T,
wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>,
wait: NoInfer<DebounceTimer<T>>,
options?: NoInfer<DebouncedOptions<T>>,
): Resource<T> {
if (isInParamsFunction()) {
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ResourceRef, Signal} from '@angular/core';
import {DebounceTimer, ResourceRef, ResourceSnapshot, Signal, debounced} from '@angular/core';
import {FieldNode} from '../../../field/node';
import {addDefaultField} from '../../../field/validation';
import {FieldPathNode} from '../../../schema/path_node';
@@ -67,6 +67,12 @@ export interface AsyncValidatorOptions<
*/
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
/**
* Duration in milliseconds to wait before triggering the async operation, or a function that
* returns a promise that resolves when the update should proceed.
*/
readonly debounce?: DebounceTimer<TParams | undefined>;
/**
* A function that receives the resource params and returns a resource of the given params.
* 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
const pathNode = FieldPathNode.unwrapFieldPath(path);
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
(_state, params) => opts.factory(params),
(_state, params) => {
if (opts.debounce !== undefined) {
const debouncedResource = debounced(() => params(), opts.debounce);
return opts.factory(debouncedResource.value);
}
return opts.factory(params);
},
);
RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true;
@@ -7,7 +7,7 @@
*/
import {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
import {Signal} from '@angular/core';
import {DebounceTimer, ResourceSnapshot, Signal} from '@angular/core';
import {
FieldContext,
SchemaPath,
@@ -62,6 +62,12 @@ export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKin
* The options to use when creating the httpResource.
*/
readonly options?: HttpResourceOptions<TResult, unknown>;
/**
* Duration in milliseconds to wait before triggering the async operation, or a function that
* returns a promise that resolves when the update should proceed.
*/
readonly debounce?: DebounceTimer<string | HttpResourceRequest | undefined>;
}
/**
@@ -83,7 +89,10 @@ export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKi
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
) {
validateAsync(path, {
params: opts.request,
params: opts.request as (
ctx: FieldContext<TValue, TPathKind>,
) => string | HttpResourceRequest | undefined,
debounce: opts.debounce,
factory: (request: Signal<any>) => httpResource(request, opts.options),
onSuccess: opts.onSuccess,
onError: opts.onError,
@@ -16,7 +16,7 @@ import {
type Signal,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {isNode} from '@angular/private/testing';
import {isNode, timeout, useAutoTick} from '@angular/private/testing';
import {
applyEach,
@@ -43,6 +43,8 @@ interface Address {
}
describe('resources', () => {
useAutoTick();
let appRef: ApplicationRef;
let backend: HttpTestingController;
let injector: Injector;
@@ -404,6 +406,41 @@ describe('resources', () => {
expect(f().metadata(RES)).toBe(undefined);
});
it('should support debounce in validateHttp', async () => {
const usernameForm = form(
signal('unique-user'),
(p) => {
validateHttp(p, {
request: ({value}) => `/api/check?username=${value()}`,
debounce: 50, // Short debounce
onSuccess: (available: boolean) => (available ? undefined : {kind: 'username-taken'}),
onError: () => null,
});
},
{injector},
);
TestBed.tick();
const req1 = backend.expectOne('/api/check?username=unique-user');
req1.flush(true);
await appRef.whenStable();
expect(usernameForm().valid()).toBe(true);
usernameForm().value.set('taken-user');
TestBed.tick();
// Should not have triggered a new request yet
backend.expectNone('/api/check?username=taken-user');
// Wait for debounce
await timeout(80);
TestBed.tick();
const req2 = backend.expectOne('/api/check?username=taken-user');
req2.flush(false);
await appRef.whenStable();
expect(usernameForm().valid()).toBe(false);
});
describe('reloadValidation', () => {
it('should trigger a reload of async http validation', async () => {
const usernameForm = form(