blob: 4281f4313b5671751bc4ac71b582aa53adf7f693 [file] [log] [blame]
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {Observable} from 'rxjs';
import {filter, take} from 'rxjs/operators';
import {assertIsDefined} from './common-util';
/**
* @param fn An iteratee function to be passed each element of
* the array in order. Must return a promise, and the following
* iteration will not begin until resolution of the promise returned by
* the previous iteration.
*
* An optional second argument to fn is a callback that will halt the
* loop if called.
*/
export function asyncForeach<T>(
array: T[],
fn: (item: T, stopCallback: () => void) => Promise<unknown>
): Promise<T | void> {
if (!array.length) {
return Promise.resolve();
}
let stop = false;
const stopCallback = () => {
stop = true;
};
return fn(array[0], stopCallback).then(() => {
if (stop) {
return Promise.resolve();
}
return asyncForeach(array.slice(1), fn);
});
}
export const _testOnly_allTasks = new Map<number, DelayedTask>();
/**
* This is just a very simple and small wrapper around setTimeout(). Instead of
* the usual:
*
* const timer = window.setTimeout(() => {...do stuff...}, 123);
* window.clearTimeout(timer);
*
* With this class you can do:
*
* const task = new Task(() => {...do stuff...}, 123);
* task.cancel();
*
* It is just nicer to have an object for this instead of a number as a handle.
*/
export class DelayedTask {
private timer?: number;
constructor(private callback: () => void, waitMs = 0) {
this.timer = window.setTimeout(() => {
if (this.timer) _testOnly_allTasks.delete(this.timer);
this.timer = undefined;
if (this.callback) this.callback();
}, waitMs);
_testOnly_allTasks.set(this.timer, this);
}
cancel() {
if (this.isActive()) {
window.clearTimeout(this.timer);
if (this.timer) _testOnly_allTasks.delete(this.timer);
this.timer = undefined;
}
}
flush() {
if (this.isActive()) {
this.cancel();
if (this.callback) this.callback();
}
}
isActive() {
return this.timer !== undefined;
}
}
/**
* The usage pattern is:
*
* this.myDebouncedTask = debounce(this.myDebouncedTask, () => {...}, 123);
*
* It is identical to:
*
* this.myTask = new DelayedTask(() => {...}, 123);
*
* But it would cancel a potentially scheduled task beforehand.
*/
export function debounce(
existingTask: DelayedTask | undefined,
callback: () => void,
waitMs = 0
) {
existingTask?.cancel();
return new DelayedTask(callback, waitMs);
}
export const DELAYED_CANCELLATION = Symbol('Delayed Cancellation');
export class DelayedPromise<T> extends Promise<T> {
private resolve: (value: PromiseLike<T> | T) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private reject: (reason?: any) => void;
private timer: number | undefined;
constructor(private readonly callback: () => Promise<T>, waitMs = 0) {
let resolve: ((value: PromiseLike<T> | T) => void) | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let reject: ((reason?: any) => void) | undefined;
super((res, rej) => {
resolve = res;
reject = rej;
});
assertIsDefined(resolve);
assertIsDefined(reject);
this.resolve = resolve;
this.reject = reject;
this.timer = window.setTimeout(async () => {
await this.flush();
}, waitMs);
}
private stop() {
if (this.timer === undefined) return false;
window.clearTimeout(this.timer);
this.timer = undefined;
return true;
}
async flush() {
if (!this.stop()) return;
try {
this.resolve(await this.callback());
} catch (e) {
this.reject(e);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cancel(reason?: any) {
if (!this.stop()) return;
this.reject(reason ?? DELAYED_CANCELLATION);
}
delegate(other: Promise<T>) {
if (!this.stop()) return;
other
.then((value: T) => this.resolve(value))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch((reason?: any) => this.reject(reason));
}
// From ECMAScript specification:
// https://tc39.es/ecma262/#sec-get-promise-@@species
// Promise prototype methods normally use their this value's constructor to
// create a derived object. However, a subclass constructor may over-ride
// that default behaviour by redefining its @@species property.
// NOTE: This is required otherwise .then and .catch on a DelayedPromise
// will try to instantiate a DelayedPromise with 'resolve, reject' arguments.
static override get [Symbol.species]() {
return Promise;
}
override get [Symbol.toStringTag]() {
return 'DelayedPromise';
}
}
/**
* The usage pattern is
* this.aDebouncedPromise = debounceP(this.aDebouncedPromise, () => {...}, 123)
*/
export function debounceP<T>(
existingPromise: DelayedPromise<T> | undefined,
callback: () => Promise<T>,
waitMs = 0
): DelayedPromise<T> {
const promise = new DelayedPromise<T>(callback, waitMs);
if (existingPromise) existingPromise.delegate(promise);
return promise;
}
const THROTTLE_INTERVAL_MS = 500;
/**
* Ensure only one call is made within THROTTLE_INTERVAL_MS and any call within
* this interval is ignored
*/
export function throttleWrap<T>(fn: (e: T) => void) {
let lastCall: number | undefined;
return (e: T) => {
if (
lastCall !== undefined &&
Date.now() - lastCall < THROTTLE_INTERVAL_MS
) {
return;
}
lastCall = Date.now();
fn(e);
};
}
/**
* Let's you wait for an Observable to become true.
*/
export function until<T>(obs$: Observable<T>, predicate: (t: T) => boolean) {
return new Promise<void>(resolve => {
obs$.pipe(filter(predicate), take(1)).subscribe(() => {
resolve();
});
});
}
export const isFalse = (b: boolean) => b === false;
export type PromiseResult<T> =
| {status: 'fulfilled'; value: T}
| {status: 'rejected'; reason: string};
export function isFulfilled<T>(
promiseResult?: PromiseResult<T>
): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
return promiseResult?.status === 'fulfilled';
}
// An equivalent to Promise.allSettled from ES2020.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
// TODO: Migrate our tooling to ES2020 and remove this method.
export function allSettled<T>(
promises: Promise<T>[]
): Promise<PromiseResult<T>[]> {
return Promise.all(
promises.map(promise =>
promise
.then(value => ({status: 'fulfilled', value} as const))
.catch(reason => ({status: 'rejected', reason} as const))
)
);
}