feat(forms): add 'blur' option to debounce rule

Expands the `debounce` rule configuration to accept `'blur'`. When this option
is provided, the rule will delay model synchronization until the field loses
focus (is touched). This introduces a debouncer that defers resolution
until the framework automatically aborts pending debounces upon touch events.
This commit is contained in:
Leon Senft 2026-03-05 09:55:14 -08:00 committed by GitHub
parent f01901d766
commit c767d678cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 11 deletions

View file

@ -91,7 +91,7 @@ export function createMetadataKey<TWrite>(): MetadataKey<Signal<TWrite | undefin
export function createMetadataKey<TWrite, TAcc>(reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<Signal<TAcc>, TWrite, TAcc>;
// @public
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, durationOrDebouncer: number | Debouncer<TValue, TPathKind>): void;
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, config: number | 'blur' | Debouncer<TValue, TPathKind>): void;
// @public
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (context: FieldContext<TValue, TPathKind>, abortSignal: AbortSignal) => Promise<void> | void;

View file

@ -18,27 +18,45 @@ import type {Debouncer, PathKind, SchemaPath, SchemaPathRules} from '../types';
* the field is touched, or the most recently debounced update resolves.
*
* @param path The target path to debounce.
* @param durationOrDebouncer Either a debounce duration in milliseconds, or a custom
* {@link Debouncer} function.
* @param config A debounce configuration, which can be either a debounce duration in milliseconds,
* `'blur'` to debounce until the field is blurred, or a custom {@link Debouncer} function.
*
* @experimental 21.0.0
*/
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
durationOrDebouncer: number | Debouncer<TValue, TPathKind>,
config: number | 'blur' | Debouncer<TValue, TPathKind>,
): void {
assertPathIsCurrent(path);
const pathNode = FieldPathNode.unwrapFieldPath(path);
const debouncer =
typeof durationOrDebouncer === 'function'
? durationOrDebouncer
: durationOrDebouncer > 0
? debounceForDuration(durationOrDebouncer)
: immediate;
const debouncer = normalizeDebouncer(config);
pathNode.builder.addMetadataRule(DEBOUNCER, () => debouncer);
}
function normalizeDebouncer<TValue, TPathKind extends PathKind>(
debouncer: number | 'blur' | Debouncer<TValue, TPathKind>,
) {
// If it's already a debounce function, return it as-is.
if (typeof debouncer === 'function') {
return debouncer;
}
// If it's 'blur', return a debouncer that never resolves. The field will still be updated when
// the control is blurred.
if (debouncer === 'blur') {
return debounceUntilBlur();
}
// If it's a non-zero number, return a timer-based debouncer.
if (debouncer > 0) {
return debounceForDuration(debouncer);
}
// Otherwise it's 0, so we return a function that will synchronize the model without delay.
return immediate;
}
/**
* Creates a debouncer that will wait for the given duration before resolving.
*/
function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown> {
return (_context, abortSignal) => {
return new Promise((resolve) => {
@ -59,4 +77,16 @@ function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown>
};
}
function immediate() {}
/**
* Creates a debouncer that will wait indefinitely, relying on the node to synchronize pending
* updates when blurred.
*/
function debounceUntilBlur(): Debouncer<unknown> {
return (_context, abortSignal) => {
return new Promise((resolve) => {
abortSignal.addEventListener('abort', () => resolve(), {once: true});
});
};
}
function immediate(): void {}

View file

@ -268,6 +268,49 @@ describe('debounce', () => {
});
});
describe('until blurred', () => {
it('should synchronize value immediately on touch', () => {
const address = signal({street: ''});
const addressForm = form(
address,
(address) => {
debounce(address.street, 'blur');
},
options(),
);
const street = addressForm.street();
street.controlValue.set('1600 Amphitheatre Pkwy');
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
expect(street.value()).toBe('');
street.markAsTouched();
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
});
it('should be ignored if value is directly set before blur', () => {
const address = signal({street: ''});
const addressForm = form(
address,
(address) => {
debounce(address.street, 'blur');
},
options(),
);
const street = addressForm.street();
street.controlValue.set('1600 Amphitheatre Pkwy');
expect(street.value()).toBe('');
street.value.set('2000 N Shoreline Blvd');
expect(street.value()).toBe('2000 N Shoreline Blvd');
expect(street.controlValue()).toBe('2000 N Shoreline Blvd');
street.markAsTouched();
expect(street.value()).toBe('2000 N Shoreline Blvd');
});
});
describe('inheritance', () => {
it('should inherit debounce from parent', async () => {
const address = signal({street: ''});