Skip to content

Commit a278ee3

Browse files
leonsenftAndrewKushnir
authored andcommitted
feat(forms): add debounce() rule for signal forms
The `debounce()` rule allows developers to control when changes to a form control are synchronized to the form model. This feature necessitated some changes to `FieldState`: * `controlValue` is a new signal property that represents the current value of a form field as it appears in its corresponding control. * `value` conceptually remains unchanged; however, its value may lag behind that of `controlValue` if a `debounce()` rule is applied. The `debounce()` rule essentially manages when changes to `controlValue` are synchronized to `value`. The intent is that an expensive or slow validation rule can react to the debounced `value`, rather than a more frequently changing `controlValue`. Directly updating `value` immediately updates `controlValue`, and cancels any pending debounced updates. When multiple `debounce()` rules are applied to the same field, the last currently active rule is used to debounce an update. These rules are applied to child fields as well, unless they override them with their own rule. (cherry picked from commit d337cfb)
1 parent 33ed7d1 commit a278ee3

File tree

12 files changed

+633
-28
lines changed

12 files changed

+633
-28
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export class CustomValidationError implements ValidationError {
107107
readonly message?: string;
108108
}
109109

110+
// @public
111+
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, durationOrDebouncer: number | Debouncer<TValue, TPathKind>): void;
112+
113+
// @public
114+
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (context: FieldContext<TValue, TPathKind>) => Promise<void> | void;
115+
110116
// @public
111117
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
112118

packages/core/src/render3/instructions/control.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,7 @@ function listenToCustomControl(
258258
componentIndex,
259259
outputName,
260260
outputName,
261-
wrapListener(tNode, lView, (newValue: unknown) => {
262-
const state = control.state();
263-
state.value.set(newValue);
264-
state.markAsDirty();
265-
}),
261+
wrapListener(tNode, lView, (value: unknown) => control.state().setControlValue(value)),
266262
);
267263

268264
const tView = getTView();
@@ -275,9 +271,7 @@ function listenToCustomControl(
275271
componentIndex,
276272
touchedOutputName,
277273
touchedOutputName,
278-
wrapListener(tNode, lView, () => {
279-
control.state().markAsTouched();
280-
}),
274+
wrapListener(tNode, lView, () => control.state().markAsTouched()),
281275
);
282276
}
283277
}
@@ -336,8 +330,7 @@ function listenToNativeControl(lView: LView<{} | null>, tNode: TNode, control:
336330

337331
const inputListener = () => {
338332
const state = control.state();
339-
state.value.set(getNativeControlValue(element, state.value));
340-
state.markAsDirty();
333+
state.setControlValue(getNativeControlValue(element, state.value));
341334
};
342335
listenToDomEvent(
343336
tNode,
@@ -463,7 +456,7 @@ function updateCustomControl(
463456
const state = control.state();
464457
const bindings = getControlBindings(lView);
465458

466-
maybeUpdateInput(componentDef, component, bindings, state, VALUE, modelName);
459+
maybeUpdateInput(componentDef, component, bindings, state, CONTROL_VALUE, modelName);
467460

468461
for (const key of CONTROL_BINDING_KEYS) {
469462
const inputName = CONTROL_BINDING_NAMES[key];
@@ -510,7 +503,7 @@ function updateInteropControl(lView: LView, control: ɵControl<unknown>): void {
510503
const state = control.state();
511504

512505
const value = state.value();
513-
if (controlBindingUpdated(bindings, VALUE, value)) {
506+
if (controlBindingUpdated(bindings, CONTROL_VALUE, value)) {
514507
// We don't know if the interop control has underlying signals, so we must use `untracked` to
515508
// prevent writing to a signal in a reactive context.
516509
untracked(() => interopControl.writeValue(value));
@@ -538,9 +531,9 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unkn
538531
const state = control.state();
539532
const bindings = getControlBindings(lView);
540533

541-
const value = state.value();
542-
if (controlBindingUpdated(bindings, VALUE, value)) {
543-
setNativeControlValue(element, value);
534+
const controlValue = state.controlValue();
535+
if (controlBindingUpdated(bindings, CONTROL_VALUE, controlValue)) {
536+
setNativeControlValue(element, controlValue);
544537
}
545538

546539
const name = state.name();
@@ -783,8 +776,10 @@ const REQUIRED = /* @__PURE__ */ getClosureSafeProperty({
783776
required: getClosureSafeProperty,
784777
}) as 'required';
785778

786-
/** A property-renaming safe reference to a property named 'value'. */
787-
const VALUE = /* @__PURE__ */ getClosureSafeProperty({value: getClosureSafeProperty}) as 'value';
779+
/** A property-renaming safe reference to a property named 'controlValue'. */
780+
const CONTROL_VALUE = /* @__PURE__ */ getClosureSafeProperty({
781+
controlValue: getClosureSafeProperty,
782+
}) as 'controlValue';
788783

789784
/**
790785
* A utility type that extracts the keys from `T` where the value type matches `TCondition`.
@@ -797,9 +792,13 @@ type KeysWithValueType<T, TCondition> = keyof {
797792

798793
/**
799794
* The keys of `ɵFieldState` that can be bound to a control.
800-
* These are the properties of `ɵFieldState` that are signals or undefined.
795+
* These are the properties of `ɵFieldState` that are signals or undefined, except for `value`
796+
* which is not bound directly, but updated indirectly through the `controlValue` binding.
801797
*/
802-
type ControlBindingKeys = KeysWithValueType<ɵFieldState<unknown>, Signal<any> | undefined>;
798+
type ControlBindingKeys = Exclude<
799+
KeysWithValueType<ɵFieldState<unknown>, Signal<any> | undefined>,
800+
'value'
801+
>;
803802

804803
/**
805804
* A map of control binding keys to their values.
@@ -812,7 +811,8 @@ type ControlBindings = {
812811
/**
813812
* A map of field state properties to control binding name.
814813
*
815-
* This excludes `value` whose corresponding control binding name differs between control types.
814+
* This excludes `controlValue` whose corresponding control binding name differs between control
815+
* types.
816816
*
817817
* The control binding name can be used for inputs or attributes (since DOM attributes are case
818818
* insensitive).
@@ -831,7 +831,7 @@ const CONTROL_BINDING_NAMES = {
831831
readonly: 'readonly',
832832
required: 'required',
833833
touched: 'touched',
834-
} as const satisfies Record<Exclude<ControlBindingKeys, 'value'>, string>;
834+
} as const satisfies Record<Exclude<ControlBindingKeys, 'controlValue'>, string>;
835835

836836
/** The keys of {@link CONTROL_BINDING_NAMES} */
837837
const CONTROL_BINDING_KEYS = /* @__PURE__ */ (() => Object.keys(CONTROL_BINDING_NAMES))() as Array<

packages/core/src/render3/interfaces/control.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,24 @@ export interface ɵFieldState<T> {
128128
readonly touched: Signal<boolean>;
129129

130130
/**
131-
* A writable signal containing the value for this field. Updating this signal will update the
132-
* data model that the field is bound to.
131+
* A writable signal containing the value for this field.
132+
*
133+
* Updating this signal will update the data model that the field is bound to.
134+
*
135+
* While updates from the UI control are eventually reflected here, they may be delayed if
136+
* debounced.
133137
*/
134138
readonly value: WritableSignal<T>;
135139

140+
/**
141+
* A signal containing the value of the control to which this field is bound.
142+
*
143+
* This differs from {@link value} in that it's not subject to debouncing, and thus is used to
144+
* buffer debounced updates from the control to the field. This will also not take into account
145+
* the {@link controlValue} of children.
146+
*/
147+
readonly controlValue: Signal<T>;
148+
136149
/**
137150
* Sets the dirty status of the field to `true`.
138151
*/
@@ -142,4 +155,9 @@ export interface ɵFieldState<T> {
142155
* Sets the touched status of the field to `true`.
143156
*/
144157
markAsTouched(): void;
158+
159+
/**
160+
* Sets {@link controlValue} immediately and triggers synchronization to {@link value}.
161+
*/
162+
setControlValue(value: T): void;
145163
}

packages/forms/signals/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414
export * from './src/api/async';
1515
export * from './src/api/control';
16+
export * from './src/api/debounce';
1617
export * from './src/api/field_directive';
1718
export * from './src/api/logic';
1819
export * from './src/api/metadata';
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {DEBOUNCER} from '../field/debounce';
10+
import {FieldPathNode} from '../schema/path_node';
11+
import {assertPathIsCurrent} from '../schema/schema';
12+
import type {Debouncer, PathKind, SchemaPath, SchemaPathRules} from './types';
13+
14+
/**
15+
* Configures the frequency at which a form field is updated by UI events.
16+
*
17+
* When this rule is applied, updates from the UI to the form model will be delayed until either
18+
* the field is touched, or the most recently debounced update resolves.
19+
*
20+
* @param path The target path to debounce.
21+
* @param durationOrDebouncer Either a debounce duration in milliseconds, or a custom
22+
* {@link Debouncer} function.
23+
*
24+
* @experimental 21.0.0
25+
*/
26+
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(
27+
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
28+
durationOrDebouncer: number | Debouncer<TValue, TPathKind>,
29+
): void {
30+
assertPathIsCurrent(path);
31+
32+
const pathNode = FieldPathNode.unwrapFieldPath(path);
33+
const debouncer =
34+
typeof durationOrDebouncer === 'function'
35+
? durationOrDebouncer
36+
: durationOrDebouncer > 0
37+
? debounceForDuration(durationOrDebouncer)
38+
: immediate;
39+
pathNode.builder.addAggregateMetadataRule(DEBOUNCER, () => debouncer);
40+
}
41+
42+
function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown> {
43+
return () => {
44+
return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds));
45+
};
46+
}
47+
48+
function immediate() {}

packages/forms/signals/src/api/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,18 @@ export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
611611
* @experimental 21.0.0
612612
*/
613613
export type ItemType<T extends Object> = T extends ReadonlyArray<any> ? T[number] : T[keyof T];
614+
615+
/**
616+
* A function that defines custom debounce logic for a field.
617+
*
618+
* This function receives the {@link FieldContext} for the field and should return a `Promise<void>`
619+
* to delay an update, or `void` to apply an update immediately.
620+
*
621+
* @template TValue The type of value stored in the field.
622+
* @template TPathKind The kind of path the debouncer is applied to (root field, child field, or item of an array).
623+
*
624+
* @experimental 21.0.0
625+
*/
626+
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (
627+
context: FieldContext<TValue, TPathKind>,
628+
) => Promise<void> | void;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {AggregateMetadataKey, reducedMetadataKey} from '../api/metadata';
10+
import {Debouncer} from '../api/types';
11+
12+
/**
13+
* A private {@link AggregateMetadataKey} used to aggregate `debounce()` rules.
14+
*
15+
* This will pick the last `debounce()` rule on a field that is currently applied, if conditional.
16+
*/
17+
export const DEBOUNCER: AggregateMetadataKey<
18+
Debouncer<any> | undefined,
19+
Debouncer<any>
20+
> = reducedMetadataKey(
21+
(_, item) => item,
22+
() => undefined,
23+
);

packages/forms/signals/src/field/node.ts

Lines changed: 59 additions & 3 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 {computed, type Signal, type WritableSignal} from '@angular/core';
9+
import {computed, linkedSignal, type Signal, type WritableSignal} from '@angular/core';
1010
import type {Field} from '../api/field_directive';
1111
import {
1212
AggregateMetadataKey,
@@ -57,10 +57,9 @@ export class FieldNode implements FieldState<unknown> {
5757
readonly metadataState: FieldMetadataState;
5858
readonly nodeState: FieldNodeState;
5959
readonly submitState: FieldSubmitState;
60-
61-
private _context: FieldContext<unknown> | undefined = undefined;
6260
readonly fieldAdapter: FieldAdapter;
6361

62+
private _context: FieldContext<unknown> | undefined = undefined;
6463
get context(): FieldContext<unknown> {
6564
return (this._context ??= new FieldNodeContext(this));
6665
}
@@ -79,6 +78,15 @@ export class FieldNode implements FieldState<unknown> {
7978
this.submitState = new FieldSubmitState(this);
8079
}
8180

81+
/**
82+
* The most recent promise returned by the debouncer, or `undefined` if no debounce is active.
83+
* This is used to ensure that only the most recent debounce operation updates the field's value.
84+
*/
85+
private readonly pendingSync: WritableSignal<Promise<void> | undefined> = linkedSignal({
86+
source: () => this.value(),
87+
computation: () => undefined,
88+
});
89+
8290
get logicNode(): LogicNode {
8391
return this.structure.logic;
8492
}
@@ -87,6 +95,11 @@ export class FieldNode implements FieldState<unknown> {
8795
return this.structure.value;
8896
}
8997

98+
private _controlValue = linkedSignal(() => this.value());
99+
get controlValue(): Signal<unknown> {
100+
return this._controlValue.asReadonly();
101+
}
102+
90103
get keyInParent(): Signal<string | number> {
91104
return this.structure.keyInParent;
92105
}
@@ -189,6 +202,7 @@ export class FieldNode implements FieldState<unknown> {
189202
*/
190203
markAsTouched(): void {
191204
this.nodeState.markAsTouched();
205+
this.sync();
192206
}
193207

194208
/**
@@ -212,6 +226,48 @@ export class FieldNode implements FieldState<unknown> {
212226
}
213227
}
214228

229+
/**
230+
* Sets the control value of the field. This value may be debounced before it is synchronized with
231+
* the field's {@link value} signal, depending on the debounce configuration.
232+
*/
233+
setControlValue(newValue: unknown): void {
234+
this._controlValue.set(newValue);
235+
this.markAsDirty();
236+
this.debounceSync();
237+
}
238+
239+
/**
240+
* Synchronizes the {@link controlValue} with the {@link value} signal immediately.
241+
*
242+
* This also clears any pending debounce operations.
243+
*/
244+
private sync() {
245+
this.value.set(this.controlValue());
246+
this.pendingSync.set(undefined);
247+
}
248+
249+
/**
250+
* Initiates a debounced {@link sync}.
251+
*
252+
* If a debouncer is configured, the synchronization will occur after the debouncer. If no
253+
* debouncer is configured, the synchronization happens immediately. If a new
254+
* {@link setControlValue} call occurs while a debounce is pending, the previous debounce
255+
* operation is ignored in favor of the new one.
256+
*/
257+
private debounceSync() {
258+
const promise = this.nodeState.debouncer();
259+
if (promise) {
260+
promise.then(() => {
261+
if (promise === this.pendingSync()) {
262+
this.sync();
263+
}
264+
});
265+
this.pendingSync.set(promise);
266+
} else {
267+
this.sync();
268+
}
269+
}
270+
215271
/**
216272
* Creates a new root field node for a new form.
217273
*/

0 commit comments

Comments
 (0)