mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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.
419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
/**
|
|
* @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<void>();
|
|
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<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds));
|
|
}
|
|
|
|
/** Returns a promise that will never resolve. */
|
|
function forever(): Promise<never> {
|
|
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<T = void>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T | PromiseLike<T>) => void;
|
|
reject: (reason?: any) => void;
|
|
} {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: any) => void;
|
|
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
|
|
return {promise, resolve, reject};
|
|
}
|