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}; +}