Skip to content

Commit c767d67

Browse files
authored
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.
1 parent f01901d commit c767d67

File tree

3 files changed

+84
-11
lines changed

3 files changed

+84
-11
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export function createMetadataKey<TWrite>(): MetadataKey<Signal<TWrite | undefin
9191
export function createMetadataKey<TWrite, TAcc>(reducer: MetadataReducer<TAcc, TWrite>): MetadataKey<Signal<TAcc>, TWrite, TAcc>;
9292

9393
// @public
94-
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, durationOrDebouncer: number | Debouncer<TValue, TPathKind>): void;
94+
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, config: number | 'blur' | Debouncer<TValue, TPathKind>): void;
9595

9696
// @public
9797
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (context: FieldContext<TValue, TPathKind>, abortSignal: AbortSignal) => Promise<void> | void;

packages/forms/signals/src/api/rules/debounce.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,45 @@ import type {Debouncer, PathKind, SchemaPath, SchemaPathRules} from '../types';
1818
* the field is touched, or the most recently debounced update resolves.
1919
*
2020
* @param path The target path to debounce.
21-
* @param durationOrDebouncer Either a debounce duration in milliseconds, or a custom
22-
* {@link Debouncer} function.
21+
* @param config A debounce configuration, which can be either a debounce duration in milliseconds,
22+
* `'blur'` to debounce until the field is blurred, or a custom {@link Debouncer} function.
2323
*
2424
* @experimental 21.0.0
2525
*/
2626
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(
2727
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
28-
durationOrDebouncer: number | Debouncer<TValue, TPathKind>,
28+
config: number | 'blur' | Debouncer<TValue, TPathKind>,
2929
): void {
3030
assertPathIsCurrent(path);
3131

3232
const pathNode = FieldPathNode.unwrapFieldPath(path);
33-
const debouncer =
34-
typeof durationOrDebouncer === 'function'
35-
? durationOrDebouncer
36-
: durationOrDebouncer > 0
37-
? debounceForDuration(durationOrDebouncer)
38-
: immediate;
33+
const debouncer = normalizeDebouncer(config);
3934
pathNode.builder.addMetadataRule(DEBOUNCER, () => debouncer);
4035
}
4136

37+
function normalizeDebouncer<TValue, TPathKind extends PathKind>(
38+
debouncer: number | 'blur' | Debouncer<TValue, TPathKind>,
39+
) {
40+
// If it's already a debounce function, return it as-is.
41+
if (typeof debouncer === 'function') {
42+
return debouncer;
43+
}
44+
// If it's 'blur', return a debouncer that never resolves. The field will still be updated when
45+
// the control is blurred.
46+
if (debouncer === 'blur') {
47+
return debounceUntilBlur();
48+
}
49+
// If it's a non-zero number, return a timer-based debouncer.
50+
if (debouncer > 0) {
51+
return debounceForDuration(debouncer);
52+
}
53+
// Otherwise it's 0, so we return a function that will synchronize the model without delay.
54+
return immediate;
55+
}
56+
57+
/**
58+
* Creates a debouncer that will wait for the given duration before resolving.
59+
*/
4260
function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown> {
4361
return (_context, abortSignal) => {
4462
return new Promise((resolve) => {
@@ -59,4 +77,16 @@ function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown>
5977
};
6078
}
6179

62-
function immediate() {}
80+
/**
81+
* Creates a debouncer that will wait indefinitely, relying on the node to synchronize pending
82+
* updates when blurred.
83+
*/
84+
function debounceUntilBlur(): Debouncer<unknown> {
85+
return (_context, abortSignal) => {
86+
return new Promise((resolve) => {
87+
abortSignal.addEventListener('abort', () => resolve(), {once: true});
88+
});
89+
};
90+
}
91+
92+
function immediate(): void {}

packages/forms/signals/test/node/api/debounce.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,49 @@ describe('debounce', () => {
268268
});
269269
});
270270

271+
describe('until blurred', () => {
272+
it('should synchronize value immediately on touch', () => {
273+
const address = signal({street: ''});
274+
const addressForm = form(
275+
address,
276+
(address) => {
277+
debounce(address.street, 'blur');
278+
},
279+
options(),
280+
);
281+
const street = addressForm.street();
282+
283+
street.controlValue.set('1600 Amphitheatre Pkwy');
284+
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
285+
expect(street.value()).toBe('');
286+
287+
street.markAsTouched();
288+
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
289+
});
290+
291+
it('should be ignored if value is directly set before blur', () => {
292+
const address = signal({street: ''});
293+
const addressForm = form(
294+
address,
295+
(address) => {
296+
debounce(address.street, 'blur');
297+
},
298+
options(),
299+
);
300+
const street = addressForm.street();
301+
302+
street.controlValue.set('1600 Amphitheatre Pkwy');
303+
expect(street.value()).toBe('');
304+
305+
street.value.set('2000 N Shoreline Blvd');
306+
expect(street.value()).toBe('2000 N Shoreline Blvd');
307+
expect(street.controlValue()).toBe('2000 N Shoreline Blvd');
308+
309+
street.markAsTouched();
310+
expect(street.value()).toBe('2000 N Shoreline Blvd');
311+
});
312+
});
313+
271314
describe('inheritance', () => {
272315
it('should inherit debounce from parent', async () => {
273316
const address = signal({street: ''});

0 commit comments

Comments
 (0)