Skip to content

Commit

Permalink
feat(async): add CancelablePromise utility class
Browse files Browse the repository at this point in the history
  • Loading branch information
cahilfoley committed Nov 28, 2019
1 parent 17def6f commit 3f7f1f6
Show file tree
Hide file tree
Showing 4 changed files with 143 additions and 4 deletions.
69 changes: 69 additions & 0 deletions src/async/CancelablePromise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { CancelablePromise, canceledError } from './CancelablePromise'

describe('CancelablePromise', () => {
const createPromise = () =>
new CancelablePromise(resolve => setTimeout(resolve, 0, 'hello world'))

const createFailingPromise = () =>
new CancelablePromise((_, reject) => setTimeout(reject, 0, 'some error'))

const createMockFns = () => ({
onFulfilled: jest.fn(val => val),
onRejected: jest.fn(val => val),
onFinally: jest.fn(),
})

it('resolves like a regular promise when not canceled', async () => {
const cancelable = createPromise()
const { onFulfilled, onRejected } = createMockFns()
cancelable.then(onFulfilled).catch(onRejected)
const value = await cancelable
expect(value).toBe('hello world')
expect(onFulfilled).toHaveBeenCalledWith('hello world')
expect(onRejected).not.toHaveBeenCalled()
})

it('rejects like a regular promise when not canceled', async () => {
const cancelable = createFailingPromise()
const { onFulfilled, onRejected } = createMockFns()
await cancelable.catch(onRejected)
expect(onFulfilled).not.toHaveBeenCalled()
expect(onRejected).toHaveBeenCalledWith('some error')
})

it('completes like a regular promise when not canceled', async () => {
const cancelable = createPromise()
const { onFulfilled, onRejected, onFinally } = createMockFns()
await cancelable.then(onFulfilled)
await cancelable.catch(onRejected)
await cancelable.finally(onFinally)
expect(onFulfilled).toHaveBeenCalledWith('hello world')
expect(onRejected).not.toHaveBeenCalled()
expect(onFinally).toHaveBeenCalled()
})

it('stringifies to the string [object CancelablePromise]', () => {
const cancelable = createPromise()
expect(`${cancelable}`).toEqual('[object CancelablePromise]')
})

it('rejects with the constant cancelable error when canceled', async () => {
const cancelable = createPromise()
const { onFulfilled, onRejected } = createMockFns()
const check = cancelable.then(onFulfilled).catch(onRejected)
cancelable.cancel()
await check
expect(onFulfilled).not.toHaveBeenCalled()
expect(onRejected).toHaveBeenCalledWith(canceledError)
})

it('rejects with the constant cancelable error when canceled, even if the promise failed', async () => {
const cancelable = createFailingPromise()
const { onFulfilled, onRejected } = createMockFns()
const check = cancelable.then(onFulfilled).catch(onRejected)
cancelable.cancel()
await check
expect(onFulfilled).not.toHaveBeenCalled()
expect(onRejected).toHaveBeenCalledWith(canceledError)
})
})
69 changes: 69 additions & 0 deletions src/async/CancelablePromise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/** @module async */
/** */

import { canceledError } from './makeCancelable'

export { canceledError }

/**
*
* Creates a promise that can be canceled after starting. Canceling the promise does not stop it from executing but will
* cause it to reject with the value `{ isCanceled: true }` once it finishes, regardless of outcome.
*
* ```ts
*
* const promise = new CancelablePromise(res => setTimeout(res, 3000, 'I finished!'))
*
* // Stop the cancelable promise from resolving
* cancelablePromise.cancel()
*
* cancelablePromise
* .then(result => console.log('Cancelable', result)) // Never fires, the promise will not resolve after being cancelled
* .catch(err => console.log('Cancelable', err)) // Resolves after 3000ms with the value `{ isCanceled: true }`
* ```
*
*/
export class CancelablePromise<T extends any> extends Promise<T> {
private canceled = false
protected promise: Promise<T>

get [Symbol.toStringTag]() {
return 'CancelablePromise'
}

get hasCanceled() {
return this.canceled
}

constructor(executor: (resolve: (value: T) => void, reject: (err?: any) => void) => void) {
super((resolve, reject) => {
return new Promise(executor).then(
(val: T) => (this.hasCanceled ? reject(canceledError) : resolve(val)),
error => (this.hasCanceled ? reject(canceledError) : reject(error)),
)
})
}

cancel() {
this.canceled = true
}

then<TResult1, TResult2>(
onfulfilled: (value: T) => TResult1 | PromiseLike<TResult1>,
onrejected?: (reason: any) => TResult2 | PromiseLike<TResult2>,
): Promise<TResult1 | TResult2> {
return super.then(onfulfilled, onrejected)
}

catch<TResult>(
onrejected: (reason: any) => TResult | PromiseLike<TResult>,
): Promise<T | TResult> {
return super.catch(onrejected)
}

finally(onfinally: () => void): Promise<T> {
return super.finally(onfinally)
}
}

export default CancelablePromise
1 change: 1 addition & 0 deletions src/async/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
* @preferred
*/

export { default as CancelablePromise } from './CancelablePromise'
export { default as makeCancelable } from './makeCancelable'
export { default as pause } from './pause'
8 changes: 4 additions & 4 deletions src/async/makeCancelable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
export const canceledError = { isCanceled: true }

/** A promise that can have it's resolution cancelled */
export interface CancelablePromise<T> extends Promise<T> {
export interface CancelableWrappedPromise<T> extends Promise<T> {
cancel(): void
}

Expand Down Expand Up @@ -41,10 +41,10 @@ export interface CancelablePromise<T> extends Promise<T> {
* ```
*
*/
export default function makeCancelable<T>(promise: Promise<T>): CancelablePromise<T> {
export default function makeCancelable<T>(promise: Promise<T>): CancelableWrappedPromise<T> {
let hasCanceled = false

const cancelablePromise: Partial<CancelablePromise<T>> = new Promise((resolve, reject) => {
const cancelablePromise: Partial<CancelableWrappedPromise<T>> = new Promise((resolve, reject) => {
promise.then(
val => (hasCanceled ? reject(canceledError) : resolve(val)),
error => (hasCanceled ? reject(canceledError) : reject(error)),
Expand All @@ -55,5 +55,5 @@ export default function makeCancelable<T>(promise: Promise<T>): CancelablePromis
hasCanceled = true
}

return cancelablePromise as CancelablePromise<T>
return cancelablePromise as CancelableWrappedPromise<T>
}

0 comments on commit 3f7f1f6

Please # to comment.