diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 9aa2d50fbeb..dfb5ca667d5 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -91,7 +91,7 @@ export function createMetadataKey(): MetadataKey(reducer: MetadataReducer): MetadataKey, TWrite, TAcc>; // @public -export function debounce(path: SchemaPath, durationOrDebouncer: number | Debouncer): void; +export function debounce(path: SchemaPath, config: number | 'blur' | Debouncer): void; // @public export type Debouncer = (context: FieldContext, abortSignal: AbortSignal) => Promise | void; diff --git a/packages/forms/signals/src/api/rules/debounce.ts b/packages/forms/signals/src/api/rules/debounce.ts index 275634c19e9..c3ff0c9288f 100644 --- a/packages/forms/signals/src/api/rules/debounce.ts +++ b/packages/forms/signals/src/api/rules/debounce.ts @@ -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( path: SchemaPath, - durationOrDebouncer: number | Debouncer, + config: number | 'blur' | Debouncer, ): 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( + debouncer: number | 'blur' | Debouncer, +) { + // 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 { return (_context, abortSignal) => { return new Promise((resolve) => { @@ -59,4 +77,16 @@ function debounceForDuration(durationInMilliseconds: number): Debouncer }; } -function immediate() {} +/** + * Creates a debouncer that will wait indefinitely, relying on the node to synchronize pending + * updates when blurred. + */ +function debounceUntilBlur(): Debouncer { + return (_context, abortSignal) => { + return new Promise((resolve) => { + abortSignal.addEventListener('abort', () => resolve(), {once: true}); + }); + }; +} + +function immediate(): void {} diff --git a/packages/forms/signals/test/node/api/debounce.spec.ts b/packages/forms/signals/test/node/api/debounce.spec.ts index c4e24b80e8c..1e321ae46c4 100644 --- a/packages/forms/signals/test/node/api/debounce.spec.ts +++ b/packages/forms/signals/test/node/api/debounce.spec.ts @@ -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: ''});