Created
February 4, 2022 21:55
-
-
Save gvergnaud/51aa38496b555f73c2a0d7bf49b23549 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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