From d337cfb68f2aac3850d9c201747e07d85d481d83 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Fri, 17 Oct 2025 16:22:51 -0700 Subject: [PATCH] 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. --- goldens/public-api/forms/signals/index.api.md | 6 + .../core/src/render3/instructions/control.ts | 42 +- .../core/src/render3/interfaces/control.ts | 22 +- packages/forms/signals/public_api.ts | 1 + packages/forms/signals/src/api/debounce.ts | 48 ++ packages/forms/signals/src/api/types.ts | 15 + packages/forms/signals/src/field/debounce.ts | 23 + packages/forms/signals/src/field/node.ts | 62 ++- packages/forms/signals/src/field/state.ts | 19 + packages/forms/signals/src/schema/logic.ts | 2 +- .../forms/signals/src/schema/logic_node.ts | 2 +- .../signals/test/node/api/debounce.spec.ts | 419 ++++++++++++++++++ 12 files changed, 633 insertions(+), 28 deletions(-) create mode 100644 packages/forms/signals/src/api/debounce.ts create mode 100644 packages/forms/signals/src/field/debounce.ts create mode 100644 packages/forms/signals/test/node/api/debounce.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 5b4ae709b39..2cf51761554 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -107,6 +107,12 @@ export class CustomValidationError implements ValidationError { readonly message?: string; } +// @public +export function debounce(path: SchemaPath, durationOrDebouncer: number | Debouncer): void; + +// @public +export type Debouncer = (context: FieldContext) => Promise | void; + // @public export function disabled(path: SchemaPath, logic?: string | NoInfer>): void; diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 02ddd60ed57..ffc2760a6b6 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -258,11 +258,7 @@ function listenToCustomControl( componentIndex, outputName, outputName, - wrapListener(tNode, lView, (newValue: unknown) => { - const state = control.state(); - state.value.set(newValue); - state.markAsDirty(); - }), + wrapListener(tNode, lView, (value: unknown) => control.state().setControlValue(value)), ); const tView = getTView(); @@ -275,9 +271,7 @@ function listenToCustomControl( componentIndex, touchedOutputName, touchedOutputName, - wrapListener(tNode, lView, () => { - control.state().markAsTouched(); - }), + wrapListener(tNode, lView, () => control.state().markAsTouched()), ); } } @@ -336,8 +330,7 @@ function listenToNativeControl(lView: LView<{} | null>, tNode: TNode, control: const inputListener = () => { const state = control.state(); - state.value.set(getNativeControlValue(element, state.value)); - state.markAsDirty(); + state.setControlValue(getNativeControlValue(element, state.value)); }; listenToDomEvent( tNode, @@ -463,7 +456,7 @@ function updateCustomControl( const state = control.state(); const bindings = getControlBindings(lView); - maybeUpdateInput(componentDef, component, bindings, state, VALUE, modelName); + maybeUpdateInput(componentDef, component, bindings, state, CONTROL_VALUE, modelName); for (const key of CONTROL_BINDING_KEYS) { const inputName = CONTROL_BINDING_NAMES[key]; @@ -510,7 +503,7 @@ function updateInteropControl(lView: LView, control: ɵControl): void { const state = control.state(); const value = state.value(); - if (controlBindingUpdated(bindings, VALUE, value)) { + if (controlBindingUpdated(bindings, CONTROL_VALUE, value)) { // We don't know if the interop control has underlying signals, so we must use `untracked` to // prevent writing to a signal in a reactive context. untracked(() => interopControl.writeValue(value)); @@ -538,9 +531,9 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl = keyof { /** * The keys of `ɵFieldState` that can be bound to a control. - * These are the properties of `ɵFieldState` that are signals or undefined. + * These are the properties of `ɵFieldState` that are signals or undefined, except for `value` + * which is not bound directly, but updated indirectly through the `controlValue` binding. */ -type ControlBindingKeys = KeysWithValueType<ɵFieldState, Signal | undefined>; +type ControlBindingKeys = Exclude< + KeysWithValueType<ɵFieldState, Signal | undefined>, + 'value' +>; /** * A map of control binding keys to their values. @@ -812,7 +811,8 @@ type ControlBindings = { /** * A map of field state properties to control binding name. * - * This excludes `value` whose corresponding control binding name differs between control types. + * This excludes `controlValue` whose corresponding control binding name differs between control + * types. * * The control binding name can be used for inputs or attributes (since DOM attributes are case * insensitive). @@ -831,7 +831,7 @@ const CONTROL_BINDING_NAMES = { readonly: 'readonly', required: 'required', touched: 'touched', -} as const satisfies Record, string>; +} as const satisfies Record, string>; /** The keys of {@link CONTROL_BINDING_NAMES} */ const CONTROL_BINDING_KEYS = /* @__PURE__ */ (() => Object.keys(CONTROL_BINDING_NAMES))() as Array< diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index 1f3442de191..02c7fb77ca7 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -128,11 +128,24 @@ export interface ɵFieldState { readonly touched: Signal; /** - * A writable signal containing the value for this field. Updating this signal will update the - * data model that the field is bound to. + * A writable signal containing the value for this field. + * + * Updating this signal will update the data model that the field is bound to. + * + * While updates from the UI control are eventually reflected here, they may be delayed if + * debounced. */ readonly value: WritableSignal; + /** + * A signal containing the value of the control to which this field is bound. + * + * This differs from {@link value} in that it's not subject to debouncing, and thus is used to + * buffer debounced updates from the control to the field. This will also not take into account + * the {@link controlValue} of children. + */ + readonly controlValue: Signal; + /** * Sets the dirty status of the field to `true`. */ @@ -142,4 +155,9 @@ export interface ɵFieldState { * Sets the touched status of the field to `true`. */ markAsTouched(): void; + + /** + * Sets {@link controlValue} immediately and triggers synchronization to {@link value}. + */ + setControlValue(value: T): void; } diff --git a/packages/forms/signals/public_api.ts b/packages/forms/signals/public_api.ts index ac3d6b52c22..1107ebcfc47 100644 --- a/packages/forms/signals/public_api.ts +++ b/packages/forms/signals/public_api.ts @@ -13,6 +13,7 @@ */ export * from './src/api/async'; export * from './src/api/control'; +export * from './src/api/debounce'; export * from './src/api/field_directive'; export * from './src/api/logic'; export * from './src/api/metadata'; diff --git a/packages/forms/signals/src/api/debounce.ts b/packages/forms/signals/src/api/debounce.ts new file mode 100644 index 00000000000..10df5efc726 --- /dev/null +++ b/packages/forms/signals/src/api/debounce.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {DEBOUNCER} from '../field/debounce'; +import {FieldPathNode} from '../schema/path_node'; +import {assertPathIsCurrent} from '../schema/schema'; +import type {Debouncer, PathKind, SchemaPath, SchemaPathRules} from './types'; + +/** + * Configures the frequency at which a form field is updated by UI events. + * + * When this rule is applied, updates from the UI to the form model will be delayed until either + * 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. + * + * @experimental 21.0.0 + */ +export function debounce( + path: SchemaPath, + durationOrDebouncer: number | Debouncer, +): void { + assertPathIsCurrent(path); + + const pathNode = FieldPathNode.unwrapFieldPath(path); + const debouncer = + typeof durationOrDebouncer === 'function' + ? durationOrDebouncer + : durationOrDebouncer > 0 + ? debounceForDuration(durationOrDebouncer) + : immediate; + pathNode.builder.addAggregateMetadataRule(DEBOUNCER, () => debouncer); +} + +function debounceForDuration(durationInMilliseconds: number): Debouncer { + return () => { + return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds)); + }; +} + +function immediate() {} diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index 5dbc44c3b88..b014a9819cd 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -611,3 +611,18 @@ export interface ItemFieldContext extends ChildFieldContext { * @experimental 21.0.0 */ export type ItemType = T extends ReadonlyArray ? T[number] : T[keyof T]; + +/** + * A function that defines custom debounce logic for a field. + * + * This function receives the {@link FieldContext} for the field and should return a `Promise` + * to delay an update, or `void` to apply an update immediately. + * + * @template TValue The type of value stored in the field. + * @template TPathKind The kind of path the debouncer is applied to (root field, child field, or item of an array). + * + * @experimental 21.0.0 + */ +export type Debouncer = ( + context: FieldContext, +) => Promise | void; diff --git a/packages/forms/signals/src/field/debounce.ts b/packages/forms/signals/src/field/debounce.ts new file mode 100644 index 00000000000..5d83d2be05a --- /dev/null +++ b/packages/forms/signals/src/field/debounce.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {AggregateMetadataKey, reducedMetadataKey} from '../api/metadata'; +import {Debouncer} from '../api/types'; + +/** + * A private {@link AggregateMetadataKey} used to aggregate `debounce()` rules. + * + * This will pick the last `debounce()` rule on a field that is currently applied, if conditional. + */ +export const DEBOUNCER: AggregateMetadataKey< + Debouncer | undefined, + Debouncer +> = reducedMetadataKey( + (_, item) => item, + () => undefined, +); diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index cfc48b66018..852a3f72559 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, type Signal, type WritableSignal} from '@angular/core'; +import {computed, linkedSignal, type Signal, type WritableSignal} from '@angular/core'; import type {Field} from '../api/field_directive'; import { AggregateMetadataKey, @@ -57,10 +57,9 @@ export class FieldNode implements FieldState { readonly metadataState: FieldMetadataState; readonly nodeState: FieldNodeState; readonly submitState: FieldSubmitState; - - private _context: FieldContext | undefined = undefined; readonly fieldAdapter: FieldAdapter; + private _context: FieldContext | undefined = undefined; get context(): FieldContext { return (this._context ??= new FieldNodeContext(this)); } @@ -79,6 +78,15 @@ export class FieldNode implements FieldState { this.submitState = new FieldSubmitState(this); } + /** + * The most recent promise returned by the debouncer, or `undefined` if no debounce is active. + * This is used to ensure that only the most recent debounce operation updates the field's value. + */ + private readonly pendingSync: WritableSignal | undefined> = linkedSignal({ + source: () => this.value(), + computation: () => undefined, + }); + get logicNode(): LogicNode { return this.structure.logic; } @@ -87,6 +95,11 @@ export class FieldNode implements FieldState { return this.structure.value; } + private _controlValue = linkedSignal(() => this.value()); + get controlValue(): Signal { + return this._controlValue.asReadonly(); + } + get keyInParent(): Signal { return this.structure.keyInParent; } @@ -189,6 +202,7 @@ export class FieldNode implements FieldState { */ markAsTouched(): void { this.nodeState.markAsTouched(); + this.sync(); } /** @@ -212,6 +226,48 @@ export class FieldNode implements FieldState { } } + /** + * Sets the control value of the field. This value may be debounced before it is synchronized with + * the field's {@link value} signal, depending on the debounce configuration. + */ + setControlValue(newValue: unknown): void { + this._controlValue.set(newValue); + this.markAsDirty(); + this.debounceSync(); + } + + /** + * Synchronizes the {@link controlValue} with the {@link value} signal immediately. + * + * This also clears any pending debounce operations. + */ + private sync() { + this.value.set(this.controlValue()); + this.pendingSync.set(undefined); + } + + /** + * Initiates a debounced {@link sync}. + * + * If a debouncer is configured, the synchronization will occur after the debouncer. If no + * debouncer is configured, the synchronization happens immediately. If a new + * {@link setControlValue} call occurs while a debounce is pending, the previous debounce + * operation is ignored in favor of the new one. + */ + private debounceSync() { + const promise = this.nodeState.debouncer(); + if (promise) { + promise.then(() => { + if (promise === this.pendingSync()) { + this.sync(); + } + }); + this.pendingSync.set(promise); + } else { + this.sync(); + } + } + /** * Creates a new root field node for a new form. */ diff --git a/packages/forms/signals/src/field/state.ts b/packages/forms/signals/src/field/state.ts index dd3b73a43ce..8bba39e9458 100644 --- a/packages/forms/signals/src/field/state.ts +++ b/packages/forms/signals/src/field/state.ts @@ -9,6 +9,7 @@ import {computed, signal, Signal} from '@angular/core'; import type {Field} from '../api/field_directive'; import type {DisabledReason} from '../api/types'; +import {DEBOUNCER} from './debounce'; import type {FieldNode} from './node'; import {reduceChildren, shortCircuitTrue} from './util'; @@ -157,6 +158,24 @@ export class FieldNodeState { return `${parent.name()}.${this.node.structure.keyInParent()}`; }); + debouncer(): Promise | void { + if (this.node.logicNode.logic.hasAggregateMetadata(DEBOUNCER)) { + const debouncerLogic = this.node.logicNode.logic.getAggregateMetadata(DEBOUNCER); + + // Even if this field has a `debounce()` rule, it could be applied conditionally and currently + // inactive, in which case `compute()` will return undefined. + const debouncer = debouncerLogic.compute(this.node.context); + if (debouncer) { + return debouncer(this.node.context); + } + } + + // Inherit its parent's debouncer, if any. If there is no debouncer configured all the way up + // to the root field, this simply returns `undefined` indicating that the operation should not + // be debounced. + return this.node.structure.parent?.nodeState.debouncer(); + } + /** Whether this field is considered non-interactive. * * A field is considered non-interactive if one of the following is true: diff --git a/packages/forms/signals/src/schema/logic.ts b/packages/forms/signals/src/schema/logic.ts index 12324031da9..f00334687c1 100644 --- a/packages/forms/signals/src/schema/logic.ts +++ b/packages/forms/signals/src/schema/logic.ts @@ -311,7 +311,7 @@ export class LogicContainer { * @param key The `AggregateMetadataKey` for which to get the logic. * @returns The `AbstractLogic` associated with the key. */ - getAggregateMetadata(key: AggregateMetadataKey): AbstractLogic { + getAggregateMetadata(key: AggregateMetadataKey): AbstractLogic { if (!this.aggregateMetadataKeys.has(key as AggregateMetadataKey)) { this.aggregateMetadataKeys.set( key as AggregateMetadataKey, diff --git a/packages/forms/signals/src/schema/logic_node.ts b/packages/forms/signals/src/schema/logic_node.ts index 53d2c8b5db2..23fb14af8ff 100644 --- a/packages/forms/signals/src/schema/logic_node.ts +++ b/packages/forms/signals/src/schema/logic_node.ts @@ -132,7 +132,7 @@ export class LogicNodeBuilder extends AbstractLogicNodeBuilder { } override addAggregateMetadataRule( - key: AggregateMetadataKey, + key: AggregateMetadataKey, logic: LogicFn, ): void { this.getCurrent().addAggregateMetadataRule(key, logic); diff --git a/packages/forms/signals/test/node/api/debounce.spec.ts b/packages/forms/signals/test/node/api/debounce.spec.ts new file mode 100644 index 00000000000..ce6edfec1b6 --- /dev/null +++ b/packages/forms/signals/test/node/api/debounce.spec.ts @@ -0,0 +1,419 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Injector, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {applyWhenValue, debounce, form} from '@angular/forms/signals'; + +describe('debounce', () => { + describe('by duration', () => { + it('should synchronize value immediately if non-positive', () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 0); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should synchronize value after duration', async () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 1); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + await timeout(0); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should synchronize value immediately on touch', () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 1); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + street.markAsTouched(); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + }); + + describe('by function', () => { + it('should synchronize value immediately by default', () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, () => {}); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should synchronize value immediately on touch', () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, forever); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('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 synchronize value after promise resolves', async () => { + const {promise, resolve} = promiseWithResolvers(); + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, () => promise); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + resolve(); + await promise; + + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should synchronize value after most recently returned promise resolves', async () => { + const first = promiseWithResolvers(); + const second = promiseWithResolvers(); + const debounceFn = jasmine + .createSpy('debounceFn') + .and.returnValues(first.promise, second.promise); + + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, debounceFn); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + street.setControlValue('2000 N Shoreline Blvd'); + expect(street.value()).toBe(''); + + first.resolve(); + await first.promise; + expect(street.value()).toBe(''); + + second.resolve(); + await second.promise; + expect(street.value()).toBe('2000 N Shoreline Blvd'); + }); + + it('should be ignored if value is directly set before it resolves', async () => { + const debounceResult = promiseWithResolvers(); + const debounceFn = jasmine.createSpy('debounceFn').and.returnValues(debounceResult.promise); + + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, debounceFn); + }, + options(), + ); + const street = addressForm.street(); + + // Set `controlValue` which will trigger a debounce update. + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + // Directly set value during debounce duration. + street.value.set('2000 N Shoreline Blvd'); + expect(street.value()).toBe('2000 N Shoreline Blvd'); + expect(street.controlValue()).toBe('2000 N Shoreline Blvd'); + + // Wait for the debounced update, which should be ignored. + await debounceResult.resolve; + expect(street.value()).toBe('2000 N Shoreline Blvd'); + }); + }); + + describe('inheritance', () => { + it('should inherit debounce from parent', async () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address, 1); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + await timeout(0); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('can override inherited debounce', async () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address, 1); + debounce(address.street, 0); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it(`should not affect parent's debounce`, async () => { + const address = signal({street: ''}); + const addressForm = form( + address, + (address) => { + debounce(address, 1); + debounce(address.street, 0); + }, + options(), + ); + + addressForm().setControlValue({street: '1600 Amphitheatre Pkwy'}); + expect(addressForm().controlValue()).toEqual({street: '1600 Amphitheatre Pkwy'}); + expect(addressForm().value()).toEqual({street: ''}); + + await timeout(0); + expect(addressForm().value()).toEqual({street: '1600 Amphitheatre Pkwy'}); + }); + + it(`should not affect a sibling's debounce`, async () => { + const address = signal({street: '', city: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 1); + }, + options(), + ); + + addressForm.street().setControlValue('1600 Amphitheatre Pkwy'); + expect(addressForm().value()).toEqual({street: '', city: ''}); + + addressForm.city().setControlValue('Mountain View'); + expect(addressForm().value()).toEqual({street: '', city: 'Mountain View'}); + + await timeout(0); + expect(addressForm().value()).toEqual({ + street: '1600 Amphitheatre Pkwy', + city: 'Mountain View', + }); + }); + }); + + describe('aggregation', () => { + it('should apply the last debounce rule', () => { + const address = signal({street: '', city: ''}); + const addressForm = form( + address, + (address) => { + debounce(address.street, 1); + debounce(address.street, 0); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should apply the last debounce rule from schemas', async () => { + const address = signal({street: '', city: ''}); + const schema1 = (address: any) => { + debounce(address.street, 0); + }; + const schema2 = (address: any) => { + debounce(address.street, 1); + }; + const addressForm = form( + address, + (address) => { + schema1(address); + schema2(address); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + await timeout(0); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + }); + + it('should apply the last debounce rule from conditional schemas', async () => { + const address = signal({street: '', city: ''}); + const debounced = signal(false); + const addressForm = form( + address, + (address) => { + applyWhenValue( + address, + () => debounced(), + (address) => { + debounce(address.street, 0); + }, + ); + applyWhenValue( + address, + () => debounced(), + (address) => { + debounce(address.street, 1); + }, + ); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + + debounced.set(true); + street.setControlValue('2000 N Shoreline Blvd'); + expect(street.controlValue()).toBe('2000 N Shoreline Blvd'); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + + await timeout(0); + expect(street.value()).toBe('2000 N Shoreline Blvd'); + }); + + it('should apply debounce rule conditionally', async () => { + const address = signal({street: '', city: ''}); + const debounced = signal(true); + const addressForm = form( + address, + (address) => { + applyWhenValue( + address.street, + () => debounced(), + (street) => { + debounce(street, 1); + }, + ); + }, + options(), + ); + const street = addressForm.street(); + + street.setControlValue('1600 Amphitheatre Pkwy'); + expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy'); + expect(street.value()).toBe(''); + + await timeout(0); + expect(street.value()).toBe('1600 Amphitheatre Pkwy'); + + debounced.set(false); + street.setControlValue('2000 N Shoreline Blvd'); + expect(street.value()).toBe('2000 N Shoreline Blvd'); + }); + }); +}); + +/** Options for testing. */ +function options() { + return {injector: TestBed.inject(Injector)}; +} + +/** Returns a promise that will resolve after {@link durationInMilliseconds}. */ +function timeout(durationInMilliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds)); +} + +/** Returns a promise that will never resolve. */ +function forever(): Promise { + return new Promise(() => {}); +} + +/** + * Replace with `Promise.withResolvers()` once it's available. + * + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers. + */ +// TODO: share this with submit.spec.ts +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return {promise, resolve, reject}; +}