mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
Add an `AbortSignal` parameter to `Debouncer`. Implementations may
choose to accept this parameter to be informed when a debounced
operation is aborted. This may be useful for canceling pending timers or
avoiding unnecessary work.
(cherry picked from commit 98ce9a7b17)
472 lines
14 KiB
TypeScript
472 lines
14 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('abort signal', () => {
|
|
it('should be aborted if control value is set again', async () => {
|
|
const {promise, resolve} = promiseWithResolvers();
|
|
const abortSpy = jasmine.createSpy('abort');
|
|
|
|
const address = signal({street: ''});
|
|
const addressForm = form(
|
|
address,
|
|
(address) => {
|
|
debounce(address.street, (_context, signal) => {
|
|
signal.addEventListener('abort', abortSpy);
|
|
return promise;
|
|
});
|
|
},
|
|
options(),
|
|
);
|
|
const street = addressForm.street();
|
|
|
|
street.setControlValue('1600 Amphitheatre Pkwy');
|
|
expect(abortSpy).not.toHaveBeenCalled();
|
|
|
|
street.setControlValue('2000 N Shoreline Blvd');
|
|
expect(abortSpy).toHaveBeenCalledTimes(1);
|
|
|
|
resolve();
|
|
await promise;
|
|
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
|
});
|
|
|
|
it('should be aborted on touch', async () => {
|
|
const abortSpy = jasmine.createSpy('abort');
|
|
const address = signal({street: ''});
|
|
const addressForm = form(
|
|
address,
|
|
(address) => {
|
|
debounce(address.street, (_context, signal) => {
|
|
signal.addEventListener('abort', abortSpy);
|
|
return forever();
|
|
});
|
|
},
|
|
options(),
|
|
);
|
|
const street = addressForm.street();
|
|
|
|
street.setControlValue('1600 Amphitheatre Pkwy');
|
|
expect(abortSpy).not.toHaveBeenCalled();
|
|
|
|
street.markAsTouched();
|
|
expect(abortSpy).toHaveBeenCalledTimes(1);
|
|
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
|
});
|
|
});
|
|
});
|
|
|
|
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};
|
|
}
|