Skip to content

Instantly share code, notes, and snippets.

@gvergnaud
Created February 4, 2022 21:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gvergnaud/51aa38496b555f73c2a0d7bf49b23549 to your computer and use it in GitHub Desktop.
Save gvergnaud/51aa38496b555f73c2a0d7bf49b23549 to your computer and use it in GitHub Desktop.
/**
* CancelablePromise is a wrapper around a regular Promise which
* can be cancelled by calling `promise.cancel()`.
*
* To do something on cancel, return a callback inside the
* function passed to the CancelablePromise constructor.
*
* Canceled promises will neither be rejected or resolved,
* but will stay unfulfilled.
*
* example:
*
* ```ts
* new CancelablePromise((resolve, reject) => {
* // do something
* return () => {
* // on cancel
* }
* })
* ```
*
* When canceling a promise created with `.then`, `.catch`, or
* any other operator, the cancelation will be forwarded
* to the parent promise. The parent promise will be canceled
* only if **all its children promises are canceled as well**.
*
* Here is an example:
*
* ```ts
* const makeSomeHttpRequest = (resolve, reject) => {
* // xhr = ...
* return () => xhr.abort();
* }
*
* const p1 = new CancelablePromise(makeSomeHttpRequest)
*
* p2 = p1.then(...)
*
* p2.cancel() // this will cancel p1 as well because p2 is the only child promise of p1
* ```
*
* ```ts
* const p1 = new CancelablePromise(makeSomeHttpRequest)
*
* p2 = p1.then(...)
* p3 = p1.then(...)
*
* p2.cancel() // this wont cancel p1, because p3 also depends on it.
* p3.cancel() // this cancels p1 as well.
* ```
*/
export class CancelablePromise<T> implements Promise<T> {
private promise: Promise<T>;
private onCancel: () => void = () => {};
private isCanceled: boolean = false;
private consumers: Set<Promise<void>> = new Set([]);
static resolve(): CancelablePromise<void>;
// eslint-disable-next-line no-dupe-class-members
static resolve<U>(value: U | PromiseLike<U>): CancelablePromise<U>;
// eslint-disable-next-line no-dupe-class-members
static resolve<U>(value?: U | PromiseLike<U>): CancelablePromise<U | void> {
return new CancelablePromise((resolve, reject) => {
if (isPromiseLike(value)) {
value.then(resolve, reject);
if (isCancelablePromise(value)) {
return () => value.cancel();
}
} else {
resolve(value);
}
return () => {};
});
}
static reject<U = never>(reason?: any): CancelablePromise<U> {
return new CancelablePromise((_, reject) => reject(reason));
}
static all<
Promises extends readonly [...(unknown | PromiseLike<unknown>)[]]
>(promises: Promises): CancelablePromise<UnwrapAll<Promises>> {
return new CancelablePromise((resolve, reject) => {
(Promise.all(promises) as Promise<UnwrapAll<Promises>>).then(
resolve,
reject
);
return () =>
promises.forEach(promise => {
// call cancel on all promise-like that have
// a cancel method
if (isCancelablePromise(promise)) {
promise.cancel();
}
});
});
}
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void
) => void | (() => void)
) {
this.promise = new Promise((res, rej) => {
const onCancel = executor(res, rej);
if (onCancel) {
this.onCancel = onCancel;
}
});
}
[Symbol.toStringTag] = 'CancelablePromise';
private guardCanceled<U>(f: (value: U) => void): (value: U) => void {
return value => {
if (!this.isCanceled) {
f(value);
}
};
}
cancel = () => {
if (!this.isCanceled) {
this.isCanceled = true;
this.onCancel();
}
};
// redux-saga uses this method to cancel ongoing promises
// when we aren't interested in the result anymore.
['@@redux-saga/CANCEL_PROMISE'] = () => {
this.cancel();
};
then<TResult1 = T, TResult2 = never>(
onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): CancelablePromise<TResult1 | TResult2> {
return new CancelablePromise((resolve, reject) => {
let innerCancel = () => {};
const consumerPromise = this.promise
.then(
onFulfilled
? value => {
if (this.isCanceled) {
// if the promise is canceled,
// do an early return to avoid calling
// onFulfilled.
return undefined as never;
}
const res = onFulfilled(value);
if (res instanceof CancelablePromise) {
innerCancel = () => res.cancel();
}
return res as TResult1;
}
: undefined,
onRejected
? reason => {
if (this.isCanceled) {
// if the promise is canceled,
// do an early return to avoid calling
// onRejected.
return Promise.reject(reason);
}
const res = onRejected(reason);
if (res instanceof CancelablePromise) {
innerCancel = () => res.cancel();
}
return res as TResult2;
}
: undefined
)
// Do nothing if the promise is cancel
.then(this.guardCanceled(resolve), this.guardCanceled(reject));
this.consumers.add(consumerPromise);
return () => {
this.consumers.delete(consumerPromise);
// Don't cancel the current promise if
// there are still active children promises
// depending on the current promise.
if (!this.consumers.size) {
this.cancel();
}
innerCancel();
};
});
}
catch<TResult = never>(
onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null
): CancelablePromise<T | TResult> {
return new CancelablePromise((resolve, reject) => {
let innerCancel = () => {};
const consumerPromise = this.promise
.catch(
onRejected
? reason => {
if (this.isCanceled) {
// if the promise is canceled,
// do an early return to avoid calling
// onRejected.
return Promise.reject(reason);
}
const res = onRejected(reason);
if (res instanceof CancelablePromise) {
innerCancel = () => res.cancel();
}
return res;
}
: undefined
)
// Do nothing if the promise is cancel
.then(this.guardCanceled(resolve), this.guardCanceled(reject));
this.consumers.add(consumerPromise);
return () => {
this.consumers.delete(consumerPromise);
// Don't cancel the current promise if
// there are still active children promises
// depending on the current promise.
if (!this.consumers.size) {
this.cancel();
}
innerCancel();
};
});
}
finally(onFinally?: (() => void) | undefined | null): CancelablePromise<T> {
return new CancelablePromise((resolve, reject) => {
const consumerPromise = this.promise
.finally(
onFinally
? (this.guardCanceled(onFinally) as () => void)
: undefined
)
// Do nothing if the promise is cancel
.then(this.guardCanceled(resolve), this.guardCanceled(reject));
this.consumers.add(consumerPromise);
return () => {
this.consumers.delete(consumerPromise);
// Don't cancel the current promise if
// there are still active children promises
// depending on the current promise.
if (!this.consumers.size) {
this.cancel();
}
};
});
}
toPromise(): Promise<T> {
return Promise.resolve(this);
}
}
const isPromiseLike = (obj: any): obj is PromiseLike<unknown> =>
obj !== null &&
(typeof obj === 'object' || typeof obj === 'function') &&
typeof obj.then === 'function';
const isCancelablePromise = (
obj: any
): obj is PromiseLike<unknown> & { cancel: () => {} } =>
isPromiseLike(obj) && typeof (obj as any).cancel === 'function';
/**
* Takes a list of types of promises and returns a list of the types
* of their content.
*
* @example
* ```ts
* type t = UnwrapAll<[Promise<number>, string, CancelablePromise<boolean>]>;
* // t = [number, string, boolean]
* ```
*/
type UnwrapAll<Promises extends readonly any[]> = Promises extends []
? []
: Promises extends readonly [infer Head, ...infer Rest]
? [Unwrap<Head>, ...UnwrapAll<Rest>]
: Unwrap<Promises[number]>[];
type Unwrap<T> = T extends PromiseLike<infer U> ? U : T;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment