diff --git a/.config/jest.config.cjs b/.config/jest.config.cjs index d0bce18..3989e8c 100644 --- a/.config/jest.config.cjs +++ b/.config/jest.config.cjs @@ -1,13 +1,13 @@ -const path = require('path') +const path = require('path'); module.exports = { - timers: 'fake', - resetMocks: true, + timers: 'fake', + resetMocks: true, - rootDir: path.resolve(__dirname, '..'), - testMatch: ['**/__tests__/**/*.test.ts?(x)'], + rootDir: path.resolve(__dirname, '..'), + testMatch: ['**/__tests__/**/*.test.ts?(x)'], - transform: { - '^.+\\.(t|j)sx?$': ['@swc/jest'], - }, -} + transform: { + '^.+\\.(t|j)sx?$': ['@swc/jest'], + }, +}; diff --git a/.config/rollup.config.js b/.config/rollup.config.js index 8443bf8..9a9c7f6 100644 --- a/.config/rollup.config.js +++ b/.config/rollup.config.js @@ -1,16 +1,16 @@ -import typescript from '@rollup/plugin-typescript' +import typescript from '@rollup/plugin-typescript'; export default { - input: 'src/index.ts', - output: [ - { - file: 'dist/index.mjs', - format: 'es', - }, - { - dir: 'dist', - format: 'cjs', - }, - ], - plugins: [typescript({ exclude: '**/__tests__/**/*' })], -} + input: 'src/index.ts', + output: [ + { + file: 'dist/index.mjs', + format: 'es', + }, + { + dir: 'dist', + format: 'cjs', + }, + ], + plugins: [typescript({ exclude: '**/__tests__/**/*' })], +}; diff --git a/.config/typedoc.json b/.config/typedoc.json index 008ac55..dbfde56 100644 --- a/.config/typedoc.json +++ b/.config/typedoc.json @@ -1,3 +1,3 @@ { - "out": "../docs" + "out": "../docs" } diff --git a/.editorconfig b/.editorconfig index 024dc27..9d08a1a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,10 +3,7 @@ root = true [*] charset = utf-8 indent_style = space -indent_size = 4 +indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true - -[*.yml] -indent_size = 2 \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 084950c..b75a584 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,41 +1,41 @@ module.exports = { - extends: [ - 'airbnb-base', - 'airbnb-typescript/base', - 'plugin:promise/recommended', - 'plugin:jest/recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', - 'prettier', - ], - plugins: ['promise', 'jest'], - parserOptions: { - project: './tsconfig.json', - }, - ignorePatterns: ['*.cjs', 'dist/', 'docs/'], - rules: { - 'import/prefer-default-export': 'off', + extends: [ + 'airbnb-base', + 'airbnb-typescript/base', + 'plugin:promise/recommended', + 'plugin:jest/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + ], + plugins: ['promise', 'jest'], + parserOptions: { + project: './tsconfig.json', + }, + ignorePatterns: ['*.cjs', 'dist/', 'docs/'], + rules: { + 'import/prefer-default-export': 'off', - 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], - 'no-restricted-syntax': [ - 'error', - // Options from https://github.com/airbnb/javascript/blob/651280e5a22d08170187bea9a2b1697832c87ebc/packages/eslint-config-airbnb-base/rules/style.js - // with for-of removed - { - selector: 'ForInStatement', - message: - 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', - }, - { - selector: 'LabeledStatement', - message: - 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', - }, - { - selector: 'WithStatement', - message: - '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', - }, - ], - }, -} + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], + 'no-restricted-syntax': [ + 'error', + // Options from https://github.com/airbnb/javascript/blob/651280e5a22d08170187bea9a2b1697832c87ebc/packages/eslint-config-airbnb-base/rules/style.js + // with for-of removed + { + selector: 'ForInStatement', + message: + 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', + }, + { + selector: 'LabeledStatement', + message: + 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', + }, + { + selector: 'WithStatement', + message: + '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', + }, + ], + }, +}; diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index b402e67..b32b1f3 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,24 +17,24 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community Examples of unacceptable behavior include: -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting ## Enforcement Responsibilities diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5df38df..ad438c6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -20,8 +20,8 @@ A clear and concise description of what you expected to happen. **Versions** -- real-cancellable-promise version: -- Browser / Node / React Native version: +- real-cancellable-promise version: +- Browser / Node / React Native version: **Additional context** Add any other context about the problem here. diff --git a/.prettierrc.cjs b/.prettierrc.cjs index f539c7a..e340799 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -1,5 +1,3 @@ module.exports = { - semi: false, - singleQuote: true, - printWidth: 90, -} + singleQuote: true, +}; diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb5656..ba75dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,20 @@ ### Bug Fixes -- Make the `capture` function of `buildCancellablePromise` an identity function - from a type perspective. +- Make the `capture` function of `buildCancellablePromise` an identity function + from a type perspective. ## 1.1.1 ### Bug Fixes -- Fix `CancellablePromise` not being assignable to `Promise` +- Fix `CancellablePromise` not being assignable to `Promise` ## 1.1.0 ### Features -- Publish ES module +- Publish ES module ## 1.0.0 diff --git a/README.md b/README.md index 438f5ac..1f9574f 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ A simple cancellable promise implementation for JavaScript and TypeScript. [Read the announcement post for a full explanation.](https://dev.to/srmagura/announcing-real-cancellable-promise-gkd) In particular, see the "Prior art" section for a comparison to existing cancellable promise libraries. -- ⚡ Compatible with [fetch](#fetch), [axios](#axios), and - [jQuery.ajax](#jQuery) -- 🐦 Lightweight — zero dependencies and less than 1 kB minified and gzipped -- 🏭 Used in production by [Interface - Technologies](http://www.iticentral.com/) -- 💻 Optimized for TypeScript -- ⚛ Built with React in mind -- 🔎 Compatible with - [react-query](https://react-query.tanstack.com/guides/query-cancellation) - query cancellation out of the box +- ⚡ Compatible with [fetch](#fetch), [axios](#axios), and + [jQuery.ajax](#jQuery) +- 🐦 Lightweight — zero dependencies and less than 1 kB minified and gzipped +- 🏭 Used in production by [Interface + Technologies](http://www.iticentral.com/) +- 💻 Optimized for TypeScript +- ⚛ Built with React in mind +- 🔎 Compatible with + [react-query](https://react-query.tanstack.com/guides/query-cancellation) + query cancellation out of the box # The Basics @@ -22,13 +22,13 @@ yarn add real-cancellable-promise ``` ```ts -import { CancellablePromise } from 'real-cancellable-promise' +import { CancellablePromise } from 'real-cancellable-promise'; -const cancellablePromise = new CancellablePromise(normalPromise, cancel) +const cancellablePromise = new CancellablePromise(normalPromise, cancel); -cancellablePromise.cancel() +cancellablePromise.cancel(); -await cancellablePromise // throws a Cancellation object that subclasses Error +await cancellablePromise; // throws a Cancellation object that subclasses Error ``` ### Important @@ -39,7 +39,7 @@ Your `cancel` function **MUST** cause `promise` to reject with a `Cancellation` This will **NOT** work, your callbacks with still run: ```ts -new CancellablePromise(normalPromise, () => {}) +new CancellablePromise(normalPromise, () => {}); ``` # Usage with HTTP Libraries @@ -50,30 +50,30 @@ How do I convert a normal `Promise` to a `CancellablePromise`? ```ts export function cancellableFetch( - input: RequestInfo, - init: RequestInit = {} + input: RequestInfo, + init: RequestInit = {} ): CancellablePromise { - const controller = new AbortController() - - const promise = fetch(input, { - ...init, - signal: controller.signal, - }).catch((e) => { - if (e.name === 'AbortError') { - throw new Cancellation() - } - - // rethrow the original error - throw e - }) + const controller = new AbortController(); + + const promise = fetch(input, { + ...init, + signal: controller.signal, + }).catch((e) => { + if (e.name === 'AbortError') { + throw new Cancellation(); + } - return new CancellablePromise(promise, () => controller.abort()) + // rethrow the original error + throw e; + }); + + return new CancellablePromise(promise, () => controller.abort()); } // Use just like normal fetch: const cancellablePromise = cancellableFetch(url, { - /* pass options here */ -}) + /* pass options here */ +}); ```
@@ -81,37 +81,37 @@ const cancellablePromise = cancellableFetch(url, { ```ts export function cancellableFetch( - input: RequestInfo, - init: RequestInit = {} + input: RequestInfo, + init: RequestInit = {} ): CancellablePromise { - const controller = new AbortController() - - const promise = fetch(input, { - ...init, - signal: controller.signal, + const controller = new AbortController(); + + const promise = fetch(input, { + ...init, + signal: controller.signal, + }) + .then((response) => { + // Handle the response object however you want + if (!response.ok) { + throw new Error(`Fetch failed with status code ${response.status}.`); + } + + if (response.headers.get('content-type')?.includes('application/json')) { + return response.json(); + } else { + return response.text(); + } }) - .then((response) => { - // Handle the response object however you want - if (!response.ok) { - throw new Error(`Fetch failed with status code ${response.status}.`) - } - - if (response.headers.get('content-type')?.includes('application/json')) { - return response.json() - } else { - return response.text() - } - }) - .catch((e) => { - if (e.name === 'AbortError') { - throw new Cancellation() - } - - // rethrow the original error - throw e - }) - - return new CancellablePromise(promise, () => controller.abort()) + .catch((e) => { + if (e.name === 'AbortError') { + throw new Cancellation(); + } + + // rethrow the original error + throw e; + }); + + return new CancellablePromise(promise, () => controller.abort()); } ``` @@ -120,48 +120,50 @@ export function cancellableFetch( ## axios ```ts -export function cancellableAxios(config: AxiosRequestConfig): CancellablePromise { - const source = axios.CancelToken.source() - config = { ...config, cancelToken: source.token } - - const promise = axios(config) - .then((response) => response.data) - .catch((e) => { - if (e instanceof axios.Cancel) { - throw new Cancellation() - } - - // rethrow the original error - throw e - }) - - return new CancellablePromise(promise, () => source.cancel()) +export function cancellableAxios( + config: AxiosRequestConfig +): CancellablePromise { + const source = axios.CancelToken.source(); + config = { ...config, cancelToken: source.token }; + + const promise = axios(config) + .then((response) => response.data) + .catch((e) => { + if (e instanceof axios.Cancel) { + throw new Cancellation(); + } + + // rethrow the original error + throw e; + }); + + return new CancellablePromise(promise, () => source.cancel()); } // Use just like normal axios: -const cancellablePromise = cancellableAxios({ url }) +const cancellablePromise = cancellableAxios({ url }); ``` ## jQuery.ajax ```ts export function cancellableJQueryAjax( - settings: JQuery.AjaxSettings + settings: JQuery.AjaxSettings ): CancellablePromise { - const xhr = $.ajax(settings) + const xhr = $.ajax(settings); - const promise = xhr.catch((e) => { - if (e.statusText === 'abort') throw new Cancellation() + const promise = xhr.catch((e) => { + if (e.statusText === 'abort') throw new Cancellation(); - // rethrow the original error - throw e - }) + // rethrow the original error + throw e; + }); - return new CancellablePromise(promise, () => xhr.abort()) + return new CancellablePromise(promise, () => xhr.abort()); } // Use just like normal $.ajax: -const cancellablePromise = cancellableJQueryAjax({ url, dataType: 'json' }) +const cancellablePromise = cancellableJQueryAjax({ url, dataType: 'json' }); ``` [CodeSandbox: HTTP @@ -185,26 +187,28 @@ You can fix this by canceling the API call in the cleanup function of an effect. ```tsx function listBlogPosts(): CancellablePromise { - // call the API + // call the API } export function Blog() { - const [posts, setPosts] = useState([]) - - useEffect(() => { - const cancellablePromise = listBlogPosts().then(setPosts).catch(console.error) - - // The promise will get canceled when the component unmounts - return cancellablePromise.cancel - }, []) - - return ( -
- {posts.map((p) => { - /* ... */ - })} -
- ) + const [posts, setPosts] = useState([]); + + useEffect(() => { + const cancellablePromise = listBlogPosts() + .then(setPosts) + .catch(console.error); + + // The promise will get canceled when the component unmounts + return cancellablePromise.cancel; + }, []); + + return ( +
+ {posts.map((p) => { + /* ... */ + })} +
+ ); } ``` @@ -217,33 +221,33 @@ Sometimes API calls have parameters, like a search string entered by the user. ```tsx function searchUsers(searchTerm: string): CancellablePromise { - // call the API + // call the API } export function UserList() { - const [searchTerm, setSearchTerm] = useState('') - const [users, setUsers] = useState([]) - - // In a real app you should debounce the searchTerm - useEffect(() => { - const cancellablePromise = searchUsers(searchTerm) - .then(setUsers) - .catch(console.error) - - // The old API call gets canceled whenever searchTerm changes. This prevents - // setUsers from being called with incorrect results if the API calls complete - // out of order. - return cancellablePromise.cancel - }, [searchTerm]) - - return ( -
- - {users.map((u) => { - /* ... */ - })} -
- ) + const [searchTerm, setSearchTerm] = useState(''); + const [users, setUsers] = useState([]); + + // In a real app you should debounce the searchTerm + useEffect(() => { + const cancellablePromise = searchUsers(searchTerm) + .then(setUsers) + .catch(console.error); + + // The old API call gets canceled whenever searchTerm changes. This prevents + // setUsers from being called with incorrect results if the API calls complete + // out of order. + return cancellablePromise.cancel; + }, [searchTerm]); + + return ( +
+ + {users.map((u) => { + /* ... */ + })} +
+ ); } ``` @@ -259,19 +263,19 @@ already completed). ```ts function bigQuery(userId: number): CancellablePromise { - return buildCancellablePromise(async (capture) => { - const userPromise = api.user.get(userId) - const rolePromise = api.user.listRoles(userId) + return buildCancellablePromise(async (capture) => { + const userPromise = api.user.get(userId); + const rolePromise = api.user.listRoles(userId); - const [user, roles] = await capture( - CancellablePromise.all([userPromise, rolePromise]) - ) + const [user, roles] = await capture( + CancellablePromise.all([userPromise, rolePromise]) + ); - // User must be loaded before this query can run - const customer = await capture(api.customer.get(user.customerId)) + // User must be loaded before this query can run + const customer = await capture(api.customer.get(user.customerId)); - return { user, roles, customer } - }) + return { user, roles, customer }; + }); } ``` @@ -289,15 +293,15 @@ Usually, you'll want to ignore `Cancellation` objects that get thrown: ```ts try { - await capture(cancellablePromise) + await capture(cancellablePromise); } catch (e) { - if (e instanceof Cancellation) { - // do nothing — the component probably just unmounted. - // or you could do something here it's up to you 😆 - return - } + if (e instanceof Cancellation) { + // do nothing — the component probably just unmounted. + // or you could do something here it's up to you 😆 + return; + } - // log the error or display it to the user + // log the error or display it to the user } ``` @@ -307,18 +311,18 @@ Sometimes you need to call an asynchronous function that doesn't support cancellation. In this case, you can use `pseudoCancellable`: ```ts -const cancellablePromise = pseudoCancellable(normalPromise) +const cancellablePromise = pseudoCancellable(normalPromise); // Later... -cancellablePromise.cancel() +cancellablePromise.cancel(); -await cancellablePromise // throws Cancellation object if promise did not already resolve +await cancellablePromise; // throws Cancellation object if promise did not already resolve ``` ## `CancellablePromise.delay` ```ts -await CancellablePromise.delay(1000) // wait 1 second +await CancellablePromise.delay(1000); // wait 1 second ``` # Supported Platforms diff --git a/package.json b/package.json index dc4eb76..8b90014 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,69 @@ { - "name": "real-cancellable-promise", - "version": "1.1.2", - "description": "A simple cancellable promise implementation that cancels the underlying HTTP call.", - "keywords": [ - "promise", - "cancelable", - "cancellable", - "react" + "name": "real-cancellable-promise", + "version": "1.1.2", + "description": "A simple cancellable promise implementation that cancels the underlying HTTP call.", + "keywords": [ + "promise", + "cancelable", + "cancellable", + "react" + ], + "homepage": "https://github.com/srmagura/real-cancellable-promise", + "bugs": "https://github.com/srmagura/real-cancellable-promise/issues", + "repository": { + "type": "git", + "url": "https://github.com/srmagura/real-cancellable-promise.git" + }, + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "yarn clean && rollup -c .config/rollup.config.js", + "clean": "rimraf dist", + "lint": "eslint", + "lint-all": "yarn lint .", + "lint-staged": "lint-staged --no-stash", + "prepack": "yarn build", + "prettier-all": "prettier . --write", + "setup": "husky install", + "test": "jest --config .config/jest.config.cjs", + "typedoc": "typedoc --options .config/typedoc.json src/" + }, + "lint-staged": { + "*.ts?(x)": [ + "eslint --max-warnings 0 --fix", + "prettier --write" ], - "homepage": "https://github.com/srmagura/real-cancellable-promise", - "bugs": "https://github.com/srmagura/real-cancellable-promise/issues", - "repository": { - "type": "git", - "url": "https://github.com/srmagura/real-cancellable-promise.git" - }, - "license": "MIT", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "yarn clean && rollup -c .config/rollup.config.js", - "clean": "rimraf dist", - "lint": "eslint", - "lint-all": "yarn lint .", - "lint-staged": "lint-staged --no-stash", - "prepack": "yarn build", - "prettier-all": "prettier . --write", - "setup": "husky install", - "test": "jest --config .config/jest.config.cjs", - "typedoc": "typedoc --options .config/typedoc.json src/" - }, - "lint-staged": { - "*.ts?(x)": [ - "eslint --max-warnings 0 --fix", - "prettier --write" - ], - "*.{md,js,cjs,yml,json}": "prettier --write" - }, - "devDependencies": { - "@rollup/plugin-typescript": "^8.2.5", - "@swc/cli": "^0.1.51", - "@swc/core": "^1.2.92", - "@swc/jest": "^0.2.4", - "@types/jest": "^27.0.2", - "@typescript-eslint/eslint-plugin": "^4.32.0", - "@typescript-eslint/parser": "^4.32.0", - "eslint": "^7.32.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-config-airbnb-typescript": "^14.0.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-import": "^2.24.2", - "eslint-plugin-jest": "^25.0.1", - "eslint-plugin-promise": "^5.1.0", - "husky": "^7.0.2", - "jest": "^27.2.4", - "lint-staged": "^11.1.2", - "prettier": "^2.4.1", - "rimraf": "^3.0.2", - "rollup": "^2.57.0", - "tslib": "^2.3.1", - "typedoc": "^0.22.4", - "typescript": "^4.4.3" - }, - "packageManager": "yarn@3.3.1" + "*.{md,js,cjs,yml,json}": "prettier --write" + }, + "devDependencies": { + "@rollup/plugin-typescript": "^8.2.5", + "@swc/cli": "^0.1.51", + "@swc/core": "^1.2.92", + "@swc/jest": "^0.2.4", + "@types/jest": "^27.0.2", + "@typescript-eslint/eslint-plugin": "^4.32.0", + "@typescript-eslint/parser": "^4.32.0", + "eslint": "^7.32.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-config-airbnb-typescript": "^14.0.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.24.2", + "eslint-plugin-jest": "^25.0.1", + "eslint-plugin-promise": "^5.1.0", + "husky": "^7.0.2", + "jest": "^27.2.4", + "lint-staged": "^11.1.2", + "prettier": "^2.4.1", + "rimraf": "^3.0.2", + "rollup": "^2.57.0", + "tslib": "^2.3.1", + "typedoc": "^0.22.4", + "typescript": "^4.4.3" + }, + "packageManager": "yarn@3.3.1" } diff --git a/src/CancellablePromise.ts b/src/CancellablePromise.ts index abe29e5..24e9a95 100644 --- a/src/CancellablePromise.ts +++ b/src/CancellablePromise.ts @@ -1,20 +1,22 @@ -import { Cancellation } from './Cancellation' -import { noop } from './noop' +import { Cancellation } from './Cancellation'; +import { noop } from './noop'; /** * The most abstract thing we can cancel — a thenable with a cancel method. */ -export type PromiseWithCancel = PromiseLike & { cancel(): void } +export type PromiseWithCancel = PromiseLike & { cancel(): void }; /** * Determines if an arbitrary value is a thenable with a cancel method. */ -export function isPromiseWithCancel(value: unknown): value is PromiseWithCancel { - return ( - typeof value === 'object' && - typeof (value as { then?: unknown }).then === 'function' && - typeof (value as { cancel?: unknown }).cancel === 'function' - ) +export function isPromiseWithCancel( + value: unknown +): value is PromiseWithCancel { + return ( + typeof value === 'object' && + typeof (value as { then?: unknown }).then === 'function' && + typeof (value as { cancel?: unknown }).cancel === 'function' + ); } /** @@ -26,436 +28,448 @@ export function isPromiseWithCancel(value: unknown): value is PromiseWithCanc * @typeParam T what the `CancellablePromise` resolves to */ export class CancellablePromise { - /** - * As a consumer of the library, you shouldn't ever need to access - * `CancellablePromise.promise` directly. - * - * If you are subclassing `CancellablePromise` for some reason, you - * can access this property. - */ - protected readonly promise: Promise - - // IMPORTANT: When defining a new `cancel` function, - // e.g. in the implementation of `then`, - // always use an arrow function so that `this` is bound. - - /** - * Cancel the `CancellablePromise`. - */ - readonly cancel: (reason?: string) => void - - /** - * @param promise a normal promise or thenable - * @param cancel a function that cancels `promise`. **Calling `cancel` after - * `promise` has resolved must be a no-op.** - */ - constructor(promise: PromiseLike, cancel: (reason?: string) => void) { - this.promise = Promise.resolve(promise) - this.cancel = cancel + /** + * As a consumer of the library, you shouldn't ever need to access + * `CancellablePromise.promise` directly. + * + * If you are subclassing `CancellablePromise` for some reason, you + * can access this property. + */ + protected readonly promise: Promise; + + // IMPORTANT: When defining a new `cancel` function, + // e.g. in the implementation of `then`, + // always use an arrow function so that `this` is bound. + + /** + * Cancel the `CancellablePromise`. + */ + readonly cancel: (reason?: string) => void; + + /** + * @param promise a normal promise or thenable + * @param cancel a function that cancels `promise`. **Calling `cancel` after + * `promise` has resolved must be a no-op.** + */ + constructor(promise: PromiseLike, cancel: (reason?: string) => void) { + this.promise = Promise.resolve(promise); + this.cancel = cancel; + } + + /** + * Analogous to `Promise.then`. + * + * `onFulfilled` on `onRejected` can return a value, a normal promise, or a + * `CancellablePromise`. So you can make a chain a `CancellablePromise`s + * like this: + * + * ``` + * const overallPromise = cancellableAsyncFunction1() + * .then(cancellableAsyncFunction2) + * .then(cancellableAsyncFunction3) + * .then(cancellableAsyncFunction4) + * ``` + * + * Then if you call `overallPromise.cancel`, `cancel` is called on all + * `CancellablePromise`s in the chain! In practice, this means that + * whichever async operation is in progress will be canceled. + * + * @returns a new CancellablePromise + */ + then( + onFulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onRejected?: + | ((reason: any) => TResult2 | PromiseLike) // eslint-disable-line @typescript-eslint/no-explicit-any -- to match the types used for Promise in the official lib.d.ts + | undefined + | null + ): CancellablePromise { + let fulfill; + let reject; + let callbackPromiseWithCancel: PromiseWithCancel | undefined; + + if (onFulfilled) { + fulfill = (value: T): TResult1 | PromiseLike => { + const nextValue: TResult1 | PromiseLike = onFulfilled(value); + + if (isPromiseWithCancel(nextValue)) + callbackPromiseWithCancel = nextValue; + + return nextValue; + }; } - /** - * Analogous to `Promise.then`. - * - * `onFulfilled` on `onRejected` can return a value, a normal promise, or a - * `CancellablePromise`. So you can make a chain a `CancellablePromise`s - * like this: - * - * ``` - * const overallPromise = cancellableAsyncFunction1() - * .then(cancellableAsyncFunction2) - * .then(cancellableAsyncFunction3) - * .then(cancellableAsyncFunction4) - * ``` - * - * Then if you call `overallPromise.cancel`, `cancel` is called on all - * `CancellablePromise`s in the chain! In practice, this means that - * whichever async operation is in progress will be canceled. - * - * @returns a new CancellablePromise - */ - then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, - onRejected?: - | ((reason: any) => TResult2 | PromiseLike) // eslint-disable-line @typescript-eslint/no-explicit-any -- to match the types used for Promise in the official lib.d.ts - | undefined - | null - ): CancellablePromise { - let fulfill - let reject - let callbackPromiseWithCancel: PromiseWithCancel | undefined - - if (onFulfilled) { - fulfill = (value: T): TResult1 | PromiseLike => { - const nextValue: TResult1 | PromiseLike = onFulfilled(value) - - if (isPromiseWithCancel(nextValue)) callbackPromiseWithCancel = nextValue - - return nextValue - } - } - - if (onRejected) { - reject = (reason: unknown): TResult2 | PromiseLike => { - const nextValue: TResult2 | PromiseLike = onRejected(reason) - - if (isPromiseWithCancel(nextValue)) callbackPromiseWithCancel = nextValue - - return nextValue - } - } - - const newPromise = this.promise.then(fulfill, reject) - - const newCancel = () => { - this.cancel() - callbackPromiseWithCancel?.cancel() - } - - return new CancellablePromise(newPromise, newCancel) - } - - /** - * Analogous to `Promise.catch`. - */ - catch( - onRejected?: ((reason: any) => TResult | PromiseLike) | undefined | null // eslint-disable-line @typescript-eslint/no-explicit-any -- to match the types used for Promise in the official lib.d.ts - ): CancellablePromise { - return this.then(undefined, onRejected) - } - - /** - * Attaches a callback that is invoked when the Promise is settled - * (fulfilled or rejected). The resolved value cannot be modified from the - * callback. - * @param onFinally The callback to execute when the Promise is settled - * (fulfilled or rejected). - * @returns A Promise for the completion of the callback. - */ - finally(onFinally?: (() => void) | undefined | null): CancellablePromise { - return new CancellablePromise(this.promise.finally(onFinally), this.cancel) - } + if (onRejected) { + reject = (reason: unknown): TResult2 | PromiseLike => { + const nextValue: TResult2 | PromiseLike = onRejected(reason); - /** - * This is necessary to make `CancellablePromise` assignable to `Promise`. - */ - // eslint-disable-next-line class-methods-use-this - get [Symbol.toStringTag](): string { - return 'CancellablePromise' - } - - /** - * Analogous to `Promise.resolve`. - * - * The returned promise should resolve even if it is canceled. The idea is - * that the promise is resolved instantaneously, so by the time the promise - * is canceled, it has already resolved. - */ - static resolve(): CancellablePromise - - static resolve(value: T): CancellablePromise - - static resolve(value?: unknown): CancellablePromise { - return new CancellablePromise(Promise.resolve(value), noop) - } - - /** - * Analogous to `Promise.reject`. - * - * Like `CancellablePromise.resolve`, canceling the returned - * `CancellablePromise` is a no-op. - * - * @param reason this should probably be an `Error` object - */ - static reject(reason?: unknown): CancellablePromise { - return new CancellablePromise(Promise.reject(reason), noop) - } + if (isPromiseWithCancel(nextValue)) + callbackPromiseWithCancel = nextValue; - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike, - T6 | PromiseLike, - T7 | PromiseLike, - T8 | PromiseLike, - T9 | PromiseLike, - T10 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike, - T6 | PromiseLike, - T7 | PromiseLike, - T8 | PromiseLike, - T9 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike, - T6 | PromiseLike, - T7 | PromiseLike, - T8 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike, - T6 | PromiseLike, - T7 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike, - T6 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5, T6]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike, - T5 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4, T5]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike, - T4 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3, T4]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [ - T1 | PromiseLike, - T2 | PromiseLike, - T3 | PromiseLike - ] - ): CancellablePromise<[T1, T2, T3]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all( - values: readonly [T1 | PromiseLike, T2 | PromiseLike] - ): CancellablePromise<[T1, T2]> - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all(values: readonly (T | PromiseLike)[]): CancellablePromise - - /** - * Analogous to `Promise.all`. - * - * @param values an array that may contain `CancellablePromise`s, promises, - * thenables, and resolved values - * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each - * of the promises passed in to `CancellablePromise.all`. - */ - static all(values: readonly unknown[]): CancellablePromise { - return new CancellablePromise(Promise.all(values), () => { - for (const value of values) { - if (isPromiseWithCancel(value)) value.cancel() - } - }) + return nextValue; + }; } - /** - * Creates a `CancellablePromise` that is resolved with an array of results - * when all of the provided `Promises` resolve or reject. - * @param values An array of `Promises`. - * @returns A new `CancellablePromise`. - */ - static allSettled( - values: T - ): CancellablePromise<{ - -readonly [P in keyof T]: PromiseSettledResult< - T[P] extends PromiseLike ? U : T[P] - > - }> - - /** - * Creates a `CancellablePromise` that is resolved with an array of results - * when all of the provided `Promise`s resolve or reject. - * - * @param values An array of `Promise`s. - * @returns A new `CancellablePromise`. Canceling it cancels all of the input - * promises. - */ - static allSettled( - values: Iterable - ): CancellablePromise ? U : T>[]> - - static allSettled(values: unknown[]): CancellablePromise { - const cancel = (): void => { - for (const value of values) { - if (isPromiseWithCancel(value)) { - value.cancel() - } - } + const newPromise = this.promise.then(fulfill, reject); + + const newCancel = () => { + this.cancel(); + callbackPromiseWithCancel?.cancel(); + }; + + return new CancellablePromise(newPromise, newCancel); + } + + /** + * Analogous to `Promise.catch`. + */ + catch( + onRejected?: + | ((reason: any) => TResult | PromiseLike) + | undefined + | null // eslint-disable-line @typescript-eslint/no-explicit-any -- to match the types used for Promise in the official lib.d.ts + ): CancellablePromise { + return this.then(undefined, onRejected); + } + + /** + * Attaches a callback that is invoked when the Promise is settled + * (fulfilled or rejected). The resolved value cannot be modified from the + * callback. + * @param onFinally The callback to execute when the Promise is settled + * (fulfilled or rejected). + * @returns A Promise for the completion of the callback. + */ + finally(onFinally?: (() => void) | undefined | null): CancellablePromise { + return new CancellablePromise(this.promise.finally(onFinally), this.cancel); + } + + /** + * This is necessary to make `CancellablePromise` assignable to `Promise`. + */ + // eslint-disable-next-line class-methods-use-this + get [Symbol.toStringTag](): string { + return 'CancellablePromise'; + } + + /** + * Analogous to `Promise.resolve`. + * + * The returned promise should resolve even if it is canceled. The idea is + * that the promise is resolved instantaneously, so by the time the promise + * is canceled, it has already resolved. + */ + static resolve(): CancellablePromise; + + static resolve(value: T): CancellablePromise; + + static resolve(value?: unknown): CancellablePromise { + return new CancellablePromise(Promise.resolve(value), noop); + } + + /** + * Analogous to `Promise.reject`. + * + * Like `CancellablePromise.resolve`, canceling the returned + * `CancellablePromise` is a no-op. + * + * @param reason this should probably be an `Error` object + */ + static reject(reason?: unknown): CancellablePromise { + return new CancellablePromise(Promise.reject(reason), noop); + } + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike, + T6 | PromiseLike, + T7 | PromiseLike, + T8 | PromiseLike, + T9 | PromiseLike, + T10 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike, + T6 | PromiseLike, + T7 | PromiseLike, + T8 | PromiseLike, + T9 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8, T9]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike, + T6 | PromiseLike, + T7 | PromiseLike, + T8 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7, T8]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike, + T6 | PromiseLike, + T7 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5, T6, T7]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike, + T6 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5, T6]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike, + T5 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4, T5]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike, + T4 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3, T4]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [ + T1 | PromiseLike, + T2 | PromiseLike, + T3 | PromiseLike + ] + ): CancellablePromise<[T1, T2, T3]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly [T1 | PromiseLike, T2 | PromiseLike] + ): CancellablePromise<[T1, T2]>; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all( + values: readonly (T | PromiseLike)[] + ): CancellablePromise; + + /** + * Analogous to `Promise.all`. + * + * @param values an array that may contain `CancellablePromise`s, promises, + * thenables, and resolved values + * @returns a [[`CancellablePromise`]], which, if canceled, will cancel each + * of the promises passed in to `CancellablePromise.all`. + */ + static all(values: readonly unknown[]): CancellablePromise { + return new CancellablePromise(Promise.all(values), () => { + for (const value of values) { + if (isPromiseWithCancel(value)) value.cancel(); + } + }); + } + + /** + * Creates a `CancellablePromise` that is resolved with an array of results + * when all of the provided `Promises` resolve or reject. + * @param values An array of `Promises`. + * @returns A new `CancellablePromise`. + */ + static allSettled( + values: T + ): CancellablePromise<{ + -readonly [P in keyof T]: PromiseSettledResult< + T[P] extends PromiseLike ? U : T[P] + >; + }>; + + /** + * Creates a `CancellablePromise` that is resolved with an array of results + * when all of the provided `Promise`s resolve or reject. + * + * @param values An array of `Promise`s. + * @returns A new `CancellablePromise`. Canceling it cancels all of the input + * promises. + */ + static allSettled( + values: Iterable + ): CancellablePromise< + PromiseSettledResult ? U : T>[] + >; + + static allSettled(values: unknown[]): CancellablePromise { + const cancel = (): void => { + for (const value of values) { + if (isPromiseWithCancel(value)) { + value.cancel(); } - - return new CancellablePromise(Promise.allSettled(values), cancel) - } - - /** - * Creates a `CancellablePromise` that is resolved or rejected when any of - * the provided `Promises` are resolved or rejected. - * @param values An array of `Promises`. - * @returns A new `CancellablePromise`. Canceling it cancels all of the input - * promises. - */ - static race( - values: readonly T[] - ): CancellablePromise ? U : T> { - const cancel = (): void => { - for (const value of values) { - if (isPromiseWithCancel(value)) { - value.cancel() - } - } + } + }; + + return new CancellablePromise(Promise.allSettled(values), cancel); + } + + /** + * Creates a `CancellablePromise` that is resolved or rejected when any of + * the provided `Promises` are resolved or rejected. + * @param values An array of `Promises`. + * @returns A new `CancellablePromise`. Canceling it cancels all of the input + * promises. + */ + static race( + values: readonly T[] + ): CancellablePromise ? U : T> { + const cancel = (): void => { + for (const value of values) { + if (isPromiseWithCancel(value)) { + value.cancel(); } - - return new CancellablePromise(Promise.race(values), cancel) - } - - // Promise.any is an ES2021 feature. Not yet implemented. - // /** - // * The any function returns a `CancellablePromise` that is fulfilled by the - // * first given promise to be fulfilled, or rejected with an `AggregateError` - // * containing an array of rejection reasons if all of the given promises are - // * rejected. It resolves all elements of the passed iterable to promises as - // * it runs this algorithm. - // * @param values An array or iterable of Promises. - // * @returns A new `CancellablePromise`. - // */ - // any(values: (T | PromiseLike)[] | Iterable>): CancellablePromise { - // return new CancellablePromise(Promise.any(values), cancel)) - // } - - /** - * @returns a `CancellablePromise` that resolves after `ms` milliseconds. - */ - static delay(ms: number): CancellablePromise { - let timer: NodeJS.Timer | undefined - let rejectFn: (reason?: unknown) => void = noop - - const promise = new Promise((resolve, reject) => { - timer = setTimeout(() => { - resolve() - rejectFn = noop - }, ms) - rejectFn = reject - }) - - return new CancellablePromise(promise, () => { - if (timer) clearTimeout(timer) - rejectFn(new Cancellation()) - }) - } + } + }; + + return new CancellablePromise(Promise.race(values), cancel); + } + + // Promise.any is an ES2021 feature. Not yet implemented. + // /** + // * The any function returns a `CancellablePromise` that is fulfilled by the + // * first given promise to be fulfilled, or rejected with an `AggregateError` + // * containing an array of rejection reasons if all of the given promises are + // * rejected. It resolves all elements of the passed iterable to promises as + // * it runs this algorithm. + // * @param values An array or iterable of Promises. + // * @returns A new `CancellablePromise`. + // */ + // any(values: (T | PromiseLike)[] | Iterable>): CancellablePromise { + // return new CancellablePromise(Promise.any(values), cancel)) + // } + + /** + * @returns a `CancellablePromise` that resolves after `ms` milliseconds. + */ + static delay(ms: number): CancellablePromise { + let timer: NodeJS.Timer | undefined; + let rejectFn: (reason?: unknown) => void = noop; + + const promise = new Promise((resolve, reject) => { + timer = setTimeout(() => { + resolve(); + rejectFn = noop; + }, ms); + rejectFn = reject; + }); + + return new CancellablePromise(promise, () => { + if (timer) clearTimeout(timer); + rejectFn(new Cancellation()); + }); + } } diff --git a/src/Cancellation.ts b/src/Cancellation.ts index 461c30c..d4cd2fa 100644 --- a/src/Cancellation.ts +++ b/src/Cancellation.ts @@ -2,7 +2,7 @@ * If canceled, a [[`CancellablePromise`]] should throw an `Cancellation` object. */ export class Cancellation extends Error { - constructor(message = 'Promise canceled.') { - super(message) - } + constructor(message = 'Promise canceled.') { + super(message); + } } diff --git a/src/__tests__/CancellablePromise.test.ts b/src/__tests__/CancellablePromise.test.ts index f442f24..df4df37 100644 --- a/src/__tests__/CancellablePromise.test.ts +++ b/src/__tests__/CancellablePromise.test.ts @@ -1,489 +1,504 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable jest/valid-expect-in-promise -- rule is bugged, see https://github.com/jest-community/eslint-plugin-jest/issues/930 */ // Jest bug: https://github.com/facebook/jest/issues/11876 -import { CancellablePromise } from '../CancellablePromise' -import { Cancellation } from '../Cancellation' -import { defaultDuration, delay, getPromise, fail } from './__helpers__' +import { CancellablePromise } from '../CancellablePromise'; +import { Cancellation } from '../Cancellation'; +import { defaultDuration, delay, getPromise, fail } from './__helpers__'; beforeEach(() => { - jest.useFakeTimers() -}) + jest.useFakeTimers(); +}); // eslint-disable-next-line jest/expect-expect -- TypeScript test it('is assignable to Promise', () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const x: Promise = getPromise(0) -}) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const x: Promise = getPromise(0); +}); test('toString', () => { - expect(getPromise(1).toString()).toBe('[object CancellablePromise]') -}) + expect(getPromise(1).toString()).toBe('[object CancellablePromise]'); +}); describe('constructor', () => { - it('supports canceling with a reason', async () => { - const p = getPromise(0, { cancellationReason: 'myReason' }) - p.cancel() + it('supports canceling with a reason', async () => { + const p = getPromise(0, { cancellationReason: 'myReason' }); + p.cancel(); - jest.runAllTimers() + jest.runAllTimers(); - await expect(p).rejects.toThrow(new Cancellation('myReason')) - }) -}) + await expect(p).rejects.toThrow(new Cancellation('myReason')); + }); +}); describe('then', () => { - it('rejects when the original promise rejects', async () => { - const p = getPromise(5, { shouldResolve: false }).then((n) => n * 2) - jest.runAllTimers() + it('rejects when the original promise rejects', async () => { + const p = getPromise(5, { shouldResolve: false }).then((n) => n * 2); + jest.runAllTimers(); - await expect(p).rejects.toThrow('myError') - }) + await expect(p).rejects.toThrow('myError'); + }); - it('executes a synchronous success callback', async () => { - const p: CancellablePromise = getPromise(5).then((n) => n * 2) - jest.runAllTimers() + it('executes a synchronous success callback', async () => { + const p: CancellablePromise = getPromise(5).then((n) => n * 2); + jest.runAllTimers(); - expect(await p).toBe(10) - }) + expect(await p).toBe(10); + }); - it('executes a synchronous failure callback', async () => { - const p: CancellablePromise = getPromise(5, { - shouldResolve: false, - }).then(undefined, (e) => { - expect(e).toBeInstanceOf(Error) - expect(e.message).toBe('myError') + it('executes a synchronous failure callback', async () => { + const p: CancellablePromise = getPromise(5, { + shouldResolve: false, + }).then(undefined, (e) => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe('myError'); - return 'handled' - }) - jest.runAllTimers() + return 'handled'; + }); + jest.runAllTimers(); - expect(await p).toBe('handled') - }) + expect(await p).toBe('handled'); + }); - it('executes an asynchronous success callback', async () => { - jest.useRealTimers() + it('executes an asynchronous success callback', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5).then((n) => getPromise(n * 2)) + const p: CancellablePromise = getPromise(5).then((n) => + getPromise(n * 2) + ); - expect(await p).toBe(10) - }) + expect(await p).toBe(10); + }); - it('executes an asynchronous failure callback', async () => { - jest.useRealTimers() + it('executes an asynchronous failure callback', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5, { - shouldResolve: false, - }).then(undefined, (e) => { - expect(e).toBeInstanceOf(Error) - expect(e.message).toBe('myError') + const p: CancellablePromise = getPromise(5, { + shouldResolve: false, + }).then(undefined, (e) => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe('myError'); - return getPromise('handled') - }) + return getPromise('handled'); + }); - expect(await p).toBe('handled') - }) + expect(await p).toBe('handled'); + }); - it('cancels the original promise', async () => { - const p = getPromise(5).then((n) => n * 2) - p.cancel() + it('cancels the original promise', async () => { + const p = getPromise(5).then((n) => n * 2); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) + await expect(p).rejects.toThrow(Cancellation); + }); - it('cancels the asynchronous success callback', async () => { - jest.useRealTimers() + it('cancels the asynchronous success callback', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5).then((n) => getPromise(n * 2)) + const p: CancellablePromise = getPromise(5).then((n) => + getPromise(n * 2) + ); - // Wait for first promise to resolve - await delay(defaultDuration * 1.5) - p.cancel() + // Wait for first promise to resolve + await delay(defaultDuration * 1.5); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) + await expect(p).rejects.toThrow(Cancellation); + }); - it('cancels the asynchronous failure callback', async () => { - jest.useRealTimers() + it('cancels the asynchronous failure callback', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5, { - shouldResolve: false, - }).then(undefined, (e) => { - expect(e).toBeInstanceOf(Error) - expect(e.message).toBe('myError') + const p: CancellablePromise = getPromise(5, { + shouldResolve: false, + }).then(undefined, (e) => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe('myError'); - return getPromise('handled') - }) + return getPromise('handled'); + }); - // Wait for first promise to resolve - await delay(defaultDuration * 1.5) - p.cancel() + // Wait for first promise to resolve + await delay(defaultDuration * 1.5); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) + await expect(p).rejects.toThrow(Cancellation); + }); - it('handles then chaining', async () => { - jest.useRealTimers() + it('handles then chaining', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5) - .then((n) => getPromise(n * 2)) - .then((n) => getPromise(n * 2)) - .then((n) => getPromise(n * 2)) + const p: CancellablePromise = getPromise(5) + .then((n) => getPromise(n * 2)) + .then((n) => getPromise(n * 2)) + .then((n) => getPromise(n * 2)); - expect(await p).toBe(40) - }) + expect(await p).toBe(40); + }); - it('cancels all promises in a then chain', async () => { - jest.useRealTimers() + it('cancels all promises in a then chain', async () => { + jest.useRealTimers(); - const p: CancellablePromise = getPromise(5) - .then((n) => getPromise(n * 2)) - .then((n) => getPromise(n * 2)) - .then((n) => getPromise(n * 2)) + const p: CancellablePromise = getPromise(5) + .then((n) => getPromise(n * 2)) + .then((n) => getPromise(n * 2)) + .then((n) => getPromise(n * 2)); - // Wait for all but the last promise to resolve - await delay(defaultDuration * 3.5) - p.cancel() + // Wait for all but the last promise to resolve + await delay(defaultDuration * 3.5); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) -}) + await expect(p).rejects.toThrow(Cancellation); + }); +}); describe('catch', () => { - it('resolves', async () => { - const p = getPromise(1).catch(fail) - jest.runAllTimers() + it('resolves', async () => { + const p = getPromise(1).catch(fail); + jest.runAllTimers(); - expect(await p).toBe(1) - }) + expect(await p).toBe(1); + }); - it('handles rejection', async () => { - /* eslint-disable jest/no-conditional-expect */ - const p = getPromise(1, { shouldResolve: false }).catch((e) => { - expect(e).toBeInstanceOf(Error) - expect(e.message).toBe('myError') + it('handles rejection', async () => { + /* eslint-disable jest/no-conditional-expect */ + const p = getPromise(1, { shouldResolve: false }).catch((e) => { + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe('myError'); - return 'handled' - }) - /* eslint-enable jest/no-conditional-expect */ + return 'handled'; + }); + /* eslint-enable jest/no-conditional-expect */ - jest.runAllTimers() + jest.runAllTimers(); - expect(await p).toBe('handled') - }) + expect(await p).toBe('handled'); + }); - it('cancels the original promise', async () => { - const errorHandler = jest.fn() - const p = getPromise(1, { shouldResolve: false }).catch(errorHandler) - p.cancel() - jest.runAllTimers() + it('cancels the original promise', async () => { + const errorHandler = jest.fn(); + const p = getPromise(1, { shouldResolve: false }).catch(errorHandler); + p.cancel(); + jest.runAllTimers(); - expect(await p).toBeUndefined() - expect(errorHandler).toHaveBeenCalledWith(new Cancellation()) - }) -}) + expect(await p).toBeUndefined(); + expect(errorHandler).toHaveBeenCalledWith(new Cancellation()); + }); +}); describe('resolve', () => { - it('resolves', async () => { - expect(await CancellablePromise.resolve(7)).toBe(7) - }) + it('resolves', async () => { + expect(await CancellablePromise.resolve(7)).toBe(7); + }); - it('resolves undefined', async () => { - const p: CancellablePromise = CancellablePromise.resolve() - expect(await p).toBeUndefined() - }) + it('resolves undefined', async () => { + const p: CancellablePromise = CancellablePromise.resolve(); + expect(await p).toBeUndefined(); + }); - it('resolves even if canceled immediately', async () => { - const p = CancellablePromise.resolve() - p.cancel() + it('resolves even if canceled immediately', async () => { + const p = CancellablePromise.resolve(); + p.cancel(); - expect(await p).toBeUndefined() - }) -}) + expect(await p).toBeUndefined(); + }); +}); describe('reject', () => { - it('rejects', async () => { - await expect(CancellablePromise.reject(new Error('test'))).rejects.toThrow('test') - }) + it('rejects', async () => { + await expect(CancellablePromise.reject(new Error('test'))).rejects.toThrow( + 'test' + ); + }); - it('rejects with undefined', async () => { - const p: CancellablePromise = CancellablePromise.reject() + it('rejects with undefined', async () => { + const p: CancellablePromise = CancellablePromise.reject(); - await expect(p).rejects.toBeUndefined() - }) + await expect(p).rejects.toBeUndefined(); + }); - it('rejects even if canceled immediately', async () => { - const p = CancellablePromise.reject() - p.cancel() + it('rejects even if canceled immediately', async () => { + const p = CancellablePromise.reject(); + p.cancel(); - await expect(p).rejects.toBeUndefined() - }) -}) + await expect(p).rejects.toBeUndefined(); + }); +}); describe('all', () => { - // eslint-disable-next-line jest/expect-expect -- TypeScript test - it('is typesafe', async () => { - const p0: CancellablePromise<0> = getPromise<0>(0) - const p1: CancellablePromise<1> = getPromise<1>(1) - const p2: CancellablePromise<2> = getPromise<2>(2) - const p3: CancellablePromise<3> = getPromise<3>(3) - const p4: CancellablePromise<4> = getPromise<4>(4) - const p5: CancellablePromise<5> = getPromise<5>(5) - const p6: CancellablePromise<6> = getPromise<6>(6) - const p7: CancellablePromise<7> = getPromise<7>(7) - const p8: CancellablePromise<8> = getPromise<8>(8) - const p9: CancellablePromise<9> = getPromise<9>(9) - jest.runAllTimers() - - function range(count: number): number[] { - const result = [] - - for (let i = 0; i < count; i++) result.push(i) - - return result - } - - /* eslint-disable @typescript-eslint/no-unused-vars */ - const y: 0[] = await CancellablePromise.all(range(20).map(() => p0)) - - const x0: 0[] = await CancellablePromise.all([p0]) - const x2: [0, 1, 2] = await CancellablePromise.all([p0, p1, p2]) - const x3: [0, 1, 2, 3] = await CancellablePromise.all([p0, p1, p2, p3]) - const x4: [0, 1, 2, 3, 4] = await CancellablePromise.all([p0, p1, p2, p3, p4]) - const x5: [0, 1, 2, 3, 4, 5] = await CancellablePromise.all([ - p0, - p1, - p2, - p3, - p4, - p5, - ]) - const x6: [0, 1, 2, 3, 4, 5, 6] = await CancellablePromise.all([ - p0, - p1, - p2, - p3, - p4, - p5, - p6, - ]) - const x7: [0, 1, 2, 3, 4, 5, 6, 7] = await CancellablePromise.all([ - p0, - p1, - p2, - p3, - p4, - p5, - p6, - p7, - ]) - const x8: [0, 1, 2, 3, 4, 5, 6, 7, 8] = await CancellablePromise.all([ - p0, - p1, - p2, - p3, - p4, - p5, - p6, - p7, - p8, - ]) - const x9: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = await CancellablePromise.all([ - p0, - p1, - p2, - p3, - p4, - p5, - p6, - p7, - p8, - p9, - ]) - /* eslint-enable @typescript-eslint/no-unused-vars */ - }) - - it('supports normal promises, thenables, and non-promises', async () => { - const [r0, r1, r2] = await CancellablePromise.all([ - Promise.resolve(0), - Promise.resolve(1) as PromiseLike, - 2, - ]) - - expect(r0).toBe(0) - expect(r1).toBe(1) - expect(r2).toBe(2) - }) - - it('returns the results of the input promises', async () => { - const p0 = getPromise<0>(0) - const p1 = getPromise<1>(1) - jest.runAllTimers() - - expect(await CancellablePromise.all([p0, p1])).toEqual([0, 1]) - }) - - it('rejects when the first promise rejects', async () => { - const promise = CancellablePromise.all([ - getPromise(0, { shouldResolve: false }), - getPromise(1), - ]) - jest.runAllTimers() - - await expect(promise).rejects.toThrow('myError') - }) - - it('cancels all the input promises', async () => { - const p0 = getPromise<0>(0) - const p1 = getPromise<1>(1) - - const all = CancellablePromise.all([p0, p1]) - all.cancel() - - await expect(all).rejects.toThrow(Cancellation) - }) -}) + // eslint-disable-next-line jest/expect-expect -- TypeScript test + it('is typesafe', async () => { + const p0: CancellablePromise<0> = getPromise<0>(0); + const p1: CancellablePromise<1> = getPromise<1>(1); + const p2: CancellablePromise<2> = getPromise<2>(2); + const p3: CancellablePromise<3> = getPromise<3>(3); + const p4: CancellablePromise<4> = getPromise<4>(4); + const p5: CancellablePromise<5> = getPromise<5>(5); + const p6: CancellablePromise<6> = getPromise<6>(6); + const p7: CancellablePromise<7> = getPromise<7>(7); + const p8: CancellablePromise<8> = getPromise<8>(8); + const p9: CancellablePromise<9> = getPromise<9>(9); + jest.runAllTimers(); + + function range(count: number): number[] { + const result = []; + + for (let i = 0; i < count; i++) result.push(i); + + return result; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + const y: 0[] = await CancellablePromise.all(range(20).map(() => p0)); + + const x0: 0[] = await CancellablePromise.all([p0]); + const x2: [0, 1, 2] = await CancellablePromise.all([p0, p1, p2]); + const x3: [0, 1, 2, 3] = await CancellablePromise.all([p0, p1, p2, p3]); + const x4: [0, 1, 2, 3, 4] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + ]); + const x5: [0, 1, 2, 3, 4, 5] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + p5, + ]); + const x6: [0, 1, 2, 3, 4, 5, 6] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + p5, + p6, + ]); + const x7: [0, 1, 2, 3, 4, 5, 6, 7] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + p5, + p6, + p7, + ]); + const x8: [0, 1, 2, 3, 4, 5, 6, 7, 8] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + p5, + p6, + p7, + p8, + ]); + const x9: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = await CancellablePromise.all([ + p0, + p1, + p2, + p3, + p4, + p5, + p6, + p7, + p8, + p9, + ]); + /* eslint-enable @typescript-eslint/no-unused-vars */ + }); + + it('supports normal promises, thenables, and non-promises', async () => { + const [r0, r1, r2] = await CancellablePromise.all([ + Promise.resolve(0), + Promise.resolve(1) as PromiseLike, + 2, + ]); + + expect(r0).toBe(0); + expect(r1).toBe(1); + expect(r2).toBe(2); + }); + + it('returns the results of the input promises', async () => { + const p0 = getPromise<0>(0); + const p1 = getPromise<1>(1); + jest.runAllTimers(); + + expect(await CancellablePromise.all([p0, p1])).toEqual([0, 1]); + }); + + it('rejects when the first promise rejects', async () => { + const promise = CancellablePromise.all([ + getPromise(0, { shouldResolve: false }), + getPromise(1), + ]); + jest.runAllTimers(); + + await expect(promise).rejects.toThrow('myError'); + }); + + it('cancels all the input promises', async () => { + const p0 = getPromise<0>(0); + const p1 = getPromise<1>(1); + + const all = CancellablePromise.all([p0, p1]); + all.cancel(); + + await expect(all).rejects.toThrow(Cancellation); + }); +}); describe('race', () => { - // eslint-disable-next-line jest/expect-expect -- testing no exception - it('never resolves if no arguments given', () => { - CancellablePromise.race([]).then(fail).catch(fail) - jest.runAllTimers() - }) - - it('resolves', async () => { - const p = CancellablePromise.race([CancellablePromise.resolve(0), getPromise(1)]) - - expect(await p).toBe(0) - }) - - it('rejects', async () => { - const p = CancellablePromise.race([ - CancellablePromise.reject(new Error('myError')), - getPromise(1), - ]) - jest.runAllTimers() - - await expect(p).rejects.toThrow('myError') - }) - - it('cancels all promises', async () => { - const p0 = getPromise(0).then(() => fail()) - const p1 = getPromise(1).then(() => fail()) - - const race = CancellablePromise.race([p0, p1]) - race.cancel() - jest.runAllTimers() - - await expect(race).rejects.toThrow(Cancellation) - }) -}) + // eslint-disable-next-line jest/expect-expect -- testing no exception + it('never resolves if no arguments given', () => { + CancellablePromise.race([]).then(fail).catch(fail); + jest.runAllTimers(); + }); + + it('resolves', async () => { + const p = CancellablePromise.race([ + CancellablePromise.resolve(0), + getPromise(1), + ]); + + expect(await p).toBe(0); + }); + + it('rejects', async () => { + const p = CancellablePromise.race([ + CancellablePromise.reject(new Error('myError')), + getPromise(1), + ]); + jest.runAllTimers(); + + await expect(p).rejects.toThrow('myError'); + }); + + it('cancels all promises', async () => { + const p0 = getPromise(0).then(() => fail()); + const p1 = getPromise(1).then(() => fail()); + + const race = CancellablePromise.race([p0, p1]); + race.cancel(); + jest.runAllTimers(); + + await expect(race).rejects.toThrow(Cancellation); + }); +}); describe('finally', () => { - const cleanup = jest.fn() + const cleanup = jest.fn(); - it('resolves', async () => { - const p = getPromise(1).finally(cleanup) + it('resolves', async () => { + const p = getPromise(1).finally(cleanup); - expect(cleanup).not.toHaveBeenCalled() - jest.runAllTimers() + expect(cleanup).not.toHaveBeenCalled(); + jest.runAllTimers(); - expect(await p).toBe(1) - expect(cleanup).toHaveBeenCalled() - }) + expect(await p).toBe(1); + expect(cleanup).toHaveBeenCalled(); + }); - it('rejects', async () => { - const p = getPromise(1, { shouldResolve: false }).finally(cleanup) + it('rejects', async () => { + const p = getPromise(1, { shouldResolve: false }).finally(cleanup); - expect(cleanup).not.toHaveBeenCalled() - jest.runAllTimers() + expect(cleanup).not.toHaveBeenCalled(); + jest.runAllTimers(); - await expect(p).rejects.toThrow() - expect(cleanup).toHaveBeenCalled() - }) + await expect(p).rejects.toThrow(); + expect(cleanup).toHaveBeenCalled(); + }); - it('rejects if callback throws', async () => { - const p = getPromise(1).finally(() => { - throw new Error('cleanupError') - }) + it('rejects if callback throws', async () => { + const p = getPromise(1).finally(() => { + throw new Error('cleanupError'); + }); - jest.runAllTimers() - await expect(p).rejects.toThrow('cleanupError') - }) + jest.runAllTimers(); + await expect(p).rejects.toThrow('cleanupError'); + }); - it('still runs the callback if the promise is canceled', async () => { - const p = getPromise(1).finally(cleanup) - p.cancel() + it('still runs the callback if the promise is canceled', async () => { + const p = getPromise(1).finally(cleanup); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - expect(cleanup).toHaveBeenCalled() - }) + await expect(p).rejects.toThrow(Cancellation); + expect(cleanup).toHaveBeenCalled(); + }); - it('cannot modify the resolved value', async () => { - const p = getPromise(1).finally(() => 2) - jest.runAllTimers() + it('cannot modify the resolved value', async () => { + const p = getPromise(1).finally(() => 2); + jest.runAllTimers(); - expect(await p).toBe(1) - }) -}) + expect(await p).toBe(1); + }); +}); describe('allSettled', () => { - it('resolves', async () => { - const p1 = CancellablePromise.resolve(1) - const p2 = delay(defaultDuration) - const p3 = getPromise(3, { shouldResolve: false }) - - jest.runAllTimers() - const [r1, r2, r3] = await CancellablePromise.allSettled([p1, p2, p3]) - expect(r1).toEqual({ status: 'fulfilled', value: 1 }) - expect(r2).toEqual({ status: 'fulfilled', value: undefined }) - expect(r3).toEqual({ status: 'rejected', reason: new Error('myError') }) - }) - - it('resolves and cancels all promises when canceled', async () => { - const p1 = getPromise(1).then(() => fail()) - const p2 = delay(defaultDuration) - const p3 = getPromise(3, { shouldResolve: false }) - - const allSettled = CancellablePromise.allSettled([p1, p2, p3]) - allSettled.cancel() - - jest.runAllTimers() - - const [r1, r2, r3] = await allSettled - expect(r1).toEqual({ status: 'rejected', reason: new Cancellation() }) - expect(r2).toEqual({ status: 'fulfilled', value: undefined }) - expect(r3).toEqual({ status: 'rejected', reason: new Cancellation() }) - }) -}) + it('resolves', async () => { + const p1 = CancellablePromise.resolve(1); + const p2 = delay(defaultDuration); + const p3 = getPromise(3, { shouldResolve: false }); + + jest.runAllTimers(); + const [r1, r2, r3] = await CancellablePromise.allSettled([p1, p2, p3]); + expect(r1).toEqual({ status: 'fulfilled', value: 1 }); + expect(r2).toEqual({ status: 'fulfilled', value: undefined }); + expect(r3).toEqual({ status: 'rejected', reason: new Error('myError') }); + }); + + it('resolves and cancels all promises when canceled', async () => { + const p1 = getPromise(1).then(() => fail()); + const p2 = delay(defaultDuration); + const p3 = getPromise(3, { shouldResolve: false }); + + const allSettled = CancellablePromise.allSettled([p1, p2, p3]); + allSettled.cancel(); + + jest.runAllTimers(); + + const [r1, r2, r3] = await allSettled; + expect(r1).toEqual({ status: 'rejected', reason: new Cancellation() }); + expect(r2).toEqual({ status: 'fulfilled', value: undefined }); + expect(r3).toEqual({ status: 'rejected', reason: new Cancellation() }); + }); +}); describe('delay', () => { - it('delays', async () => { - let resolved = false + it('delays', async () => { + let resolved = false; - const p = CancellablePromise.delay(200).then(() => { - resolved = true - return undefined - }) + const p = CancellablePromise.delay(200).then(() => { + resolved = true; + return undefined; + }); - jest.advanceTimersByTime(100) - expect(resolved).toBe(false) + jest.advanceTimersByTime(100); + expect(resolved).toBe(false); - jest.runAllTimers() - expect(await p).toBeUndefined() - expect(resolved).toBe(true) - }) + jest.runAllTimers(); + expect(await p).toBeUndefined(); + expect(resolved).toBe(true); + }); - it('can be canceled', async () => { - const p = CancellablePromise.delay(200) - p.cancel() + it('can be canceled', async () => { + const p = CancellablePromise.delay(200); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) + await expect(p).rejects.toThrow(Cancellation); + }); - test('cancel is a no-op if the promise has already resolved', async () => { - const p = CancellablePromise.delay(100) - jest.runAllTimers() + test('cancel is a no-op if the promise has already resolved', async () => { + const p = CancellablePromise.delay(100); + jest.runAllTimers(); - expect(await p).toBeUndefined() - p.cancel() - }) -}) + expect(await p).toBeUndefined(); + p.cancel(); + }); +}); diff --git a/src/__tests__/__helpers__/index.ts b/src/__tests__/__helpers__/index.ts index acd8b97..cb6c804 100644 --- a/src/__tests__/__helpers__/index.ts +++ b/src/__tests__/__helpers__/index.ts @@ -1,49 +1,49 @@ -import { CancellablePromise } from '../../CancellablePromise' -import { Cancellation } from '../../Cancellation' +import { CancellablePromise } from '../../CancellablePromise'; +import { Cancellation } from '../../Cancellation'; export function fail(reason = 'fail was called in a test.'): never { - throw new Error(reason) + throw new Error(reason); } export function delay(duration: number): Promise { - return new Promise((resolve) => setTimeout(resolve, duration)) + return new Promise((resolve) => setTimeout(resolve, duration)); } interface Options { - shouldResolve: boolean - duration: number - cancellationReason: string + shouldResolve: boolean; + duration: number; + cancellationReason: string; } -export const defaultDuration = 100 +export const defaultDuration = 100; export function getPromise( - returnValue: T, - options?: Partial + returnValue: T, + options?: Partial ): CancellablePromise { - const shouldResolve = options?.shouldResolve ?? true - const duration = options?.duration ?? defaultDuration - const cancellationReason = options?.cancellationReason - - let timer: NodeJS.Timeout | undefined - let rejectFn: (error?: unknown) => void = () => {} - - const promise = new Promise((resolve, reject) => { - rejectFn = reject - - timer = setTimeout(() => { - if (shouldResolve) { - resolve(returnValue) - } else { - reject(new Error('myError')) - } - }, duration) - }) - - function cancel(): void { - if (timer) clearTimeout(timer) - rejectFn(new Cancellation(cancellationReason)) - } - - return new CancellablePromise(promise, cancel) + const shouldResolve = options?.shouldResolve ?? true; + const duration = options?.duration ?? defaultDuration; + const cancellationReason = options?.cancellationReason; + + let timer: NodeJS.Timeout | undefined; + let rejectFn: (error?: unknown) => void = () => {}; + + const promise = new Promise((resolve, reject) => { + rejectFn = reject; + + timer = setTimeout(() => { + if (shouldResolve) { + resolve(returnValue); + } else { + reject(new Error('myError')); + } + }, duration); + }); + + function cancel(): void { + if (timer) clearTimeout(timer); + rejectFn(new Cancellation(cancellationReason)); + } + + return new CancellablePromise(promise, cancel); } diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 36db696..36cda3f 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,127 +1,127 @@ -import { CancellablePromise } from '../CancellablePromise' -import { Cancellation } from '../Cancellation' -import { buildCancellablePromise, pseudoCancellable } from '../utils' -import { defaultDuration, delay, getPromise, fail } from './__helpers__' +import { CancellablePromise } from '../CancellablePromise'; +import { Cancellation } from '../Cancellation'; +import { buildCancellablePromise, pseudoCancellable } from '../utils'; +import { defaultDuration, delay, getPromise, fail } from './__helpers__'; beforeEach(() => { - jest.useFakeTimers() -}) + jest.useFakeTimers(); +}); describe('pseudoCancellable', () => { - it('resolves', async () => { - expect(await pseudoCancellable(Promise.resolve(1))).toBe(1) - }) + it('resolves', async () => { + expect(await pseudoCancellable(Promise.resolve(1))).toBe(1); + }); - it('can be canceled', async () => { - const p = pseudoCancellable(delay(1000)) - p.cancel() + it('can be canceled', async () => { + const p = pseudoCancellable(delay(1000)); + p.cancel(); - await expect(p).rejects.toThrow(Cancellation) - }) + await expect(p).rejects.toThrow(Cancellation); + }); - test('cancel is a no-op if the promise has already resolved', async () => { - const p = pseudoCancellable(delay(1000)) - jest.runAllTimers() + test('cancel is a no-op if the promise has already resolved', async () => { + const p = pseudoCancellable(delay(1000)); + jest.runAllTimers(); - expect(await p).toBeUndefined() - p.cancel() - }) -}) + expect(await p).toBeUndefined(); + p.cancel(); + }); +}); describe('buildCancellablePromise', () => { - it('cancels a single promise', async () => { - jest.useRealTimers() + it('cancels a single promise', async () => { + jest.useRealTimers(); - const overallPromise = buildCancellablePromise(async (capture) => { - await capture(getPromise('1')) + const overallPromise = buildCancellablePromise(async (capture) => { + await capture(getPromise('1')); - await capture(getPromise('2')) - fail('Promise 2 resolved when it should have been canceled.') - }) + await capture(getPromise('2')); + fail('Promise 2 resolved when it should have been canceled.'); + }); - // Wait until promise1 resolves - await delay(defaultDuration * 1.5) + // Wait until promise1 resolves + await delay(defaultDuration * 1.5); - // This should cause promise2 to be canceled - overallPromise.cancel() + // This should cause promise2 to be canceled + overallPromise.cancel(); - await expect(overallPromise).rejects.toThrow(Cancellation) - }) + await expect(overallPromise).rejects.toThrow(Cancellation); + }); - it('cancels multiple promises', async () => { - const overallPromise = buildCancellablePromise(async (capture) => { - const promise1 = capture(getPromise('1')) - const promise2 = capture(getPromise('2')) + it('cancels multiple promises', async () => { + const overallPromise = buildCancellablePromise(async (capture) => { + const promise1 = capture(getPromise('1')); + const promise2 = capture(getPromise('2')); - try { - await promise1 - fail('promise1 resolved.') - } catch { - // do nothing - } + try { + await promise1; + fail('promise1 resolved.'); + } catch { + // do nothing + } - try { - await promise2 - fail('promise2 resolved.') - } catch { - // do nothing - } - }) + try { + await promise2; + fail('promise2 resolved.'); + } catch { + // do nothing + } + }); - overallPromise.cancel() + overallPromise.cancel(); - jest.runAllTimers() - expect(await overallPromise).toBeUndefined() - }) + jest.runAllTimers(); + expect(await overallPromise).toBeUndefined(); + }); - it('rejects when the inner function rejects', async () => { - const error = new Error() + it('rejects when the inner function rejects', async () => { + const error = new Error(); - await expect( - buildCancellablePromise(() => Promise.reject(error)) - ).rejects.toThrow(error) - }) + await expect( + buildCancellablePromise(() => Promise.reject(error)) + ).rejects.toThrow(error); + }); - test('capture does not handle promise rejections', async () => { - const error = new Error() + test('capture does not handle promise rejections', async () => { + const error = new Error(); - function callApi(): CancellablePromise { - return CancellablePromise.reject(error) - } + function callApi(): CancellablePromise { + return CancellablePromise.reject(error); + } - const p = buildCancellablePromise(async (capture) => { - await capture(callApi()) - }) + const p = buildCancellablePromise(async (capture) => { + await capture(callApi()); + }); - await expect(p).rejects.toThrow(error) - }) -}) + await expect(p).rejects.toThrow(error); + }); +}); describe('buildCancellablePromise capture', () => { - it('passes through the argument type', async () => { - jest.useRealTimers() - // this is a "compile-time" test - // it will only be tested when compiled with TypeScript (`$ yarn tsc`) - const promise = buildCancellablePromise(async (capture) => { - // we build two promises that we enhance with some additional fields - const fancyPromise1 = Object.assign(getPromise('1'), { - reportProgress: () => 0.9, - }) - const fancyPromise2 = Object.assign(getPromise('2'), { - info: 'some enhanced promise', - }) - const capturedFancyPromise1 = capture(fancyPromise1) - const capturedFancyPromise2 = capture(fancyPromise2) - // these will throw a compile time error if `capture` is not an identity function (from type perspective): - // the field `reportProgress` should be accessible on the type that passes through the `caputure` function - expect(capturedFancyPromise1.reportProgress()).toBe(0.9) - // the `info` field should be accessible even if the promise is passed through the `capture` function - expect(capturedFancyPromise2.info).toBe('some enhanced promise') - - return [await capturedFancyPromise1, await capturedFancyPromise2] - }) - const [res1, res2] = await promise - expect(res1).toBe('1') - expect(res2).toBe('2') - }) -}) + it('passes through the argument type', async () => { + jest.useRealTimers(); + // this is a "compile-time" test + // it will only be tested when compiled with TypeScript (`$ yarn tsc`) + const promise = buildCancellablePromise(async (capture) => { + // we build two promises that we enhance with some additional fields + const fancyPromise1 = Object.assign(getPromise('1'), { + reportProgress: () => 0.9, + }); + const fancyPromise2 = Object.assign(getPromise('2'), { + info: 'some enhanced promise', + }); + const capturedFancyPromise1 = capture(fancyPromise1); + const capturedFancyPromise2 = capture(fancyPromise2); + // these will throw a compile time error if `capture` is not an identity function (from type perspective): + // the field `reportProgress` should be accessible on the type that passes through the `caputure` function + expect(capturedFancyPromise1.reportProgress()).toBe(0.9); + // the `info` field should be accessible even if the promise is passed through the `capture` function + expect(capturedFancyPromise2.info).toBe('some enhanced promise'); + + return [await capturedFancyPromise1, await capturedFancyPromise2]; + }); + const [res1, res2] = await promise; + expect(res1).toBe('1'); + expect(res2).toBe('2'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 437acfc..73a69b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ -export * from './Cancellation' -export * from './CancellablePromise' -export * from './utils' +export * from './Cancellation'; +export * from './CancellablePromise'; +export * from './utils'; diff --git a/src/noop.ts b/src/noop.ts index 5f47e35..93f6827 100644 --- a/src/noop.ts +++ b/src/noop.ts @@ -1,2 +1,2 @@ /** @internal */ -export const noop = (): void => {} +export const noop = (): void => {}; diff --git a/src/utils.ts b/src/utils.ts index 45687aa..c516a6a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ -import { CancellablePromise } from './CancellablePromise' -import { Cancellation } from './Cancellation' -import { noop } from './noop' +import { CancellablePromise } from './CancellablePromise'; +import { Cancellation } from './Cancellation'; +import { noop } from './noop'; /** * Takes in a regular `Promise` and returns a `CancellablePromise`. If canceled, @@ -10,43 +10,45 @@ import { noop } from './noop' * Analogous to * [make-cancellable-promise](https://www.npmjs.com/package/make-cancellable-promise). */ -export function pseudoCancellable(promise: PromiseLike): CancellablePromise { - let canceled = false - let rejectFn: (reason?: unknown) => void = noop +export function pseudoCancellable( + promise: PromiseLike +): CancellablePromise { + let canceled = false; + let rejectFn: (reason?: unknown) => void = noop; - const newPromise = new Promise((resolve, reject) => { - rejectFn = reject + const newPromise = new Promise((resolve, reject) => { + rejectFn = reject; - // eslint-disable-next-line promise/catch-or-return -- no catch method on PromiseLike - promise.then( - (result) => { - if (!canceled) { - resolve(result) - rejectFn = noop - } + // eslint-disable-next-line promise/catch-or-return -- no catch method on PromiseLike + promise.then( + (result) => { + if (!canceled) { + resolve(result); + rejectFn = noop; + } - return undefined - }, - (e: unknown) => { - if (!canceled) reject(e) - } - ) - }) + return undefined; + }, + (e: unknown) => { + if (!canceled) reject(e); + } + ); + }); - function cancel(): void { - canceled = true - rejectFn(new Cancellation()) - } + function cancel(): void { + canceled = true; + rejectFn(new Cancellation()); + } - return new CancellablePromise(newPromise, cancel) + return new CancellablePromise(newPromise, cancel); } /** * The type of the `capture` function used in [[`buildCancellablePromise`]]. */ export type CaptureCancellablePromise =

>( - promise: P -) => P + promise: P +) => P; /** * Used to build a single [[`CancellablePromise`]] from a multi-step asynchronous @@ -73,18 +75,18 @@ export type CaptureCancellablePromise =

>( * a regular `Promise` */ export function buildCancellablePromise( - innerFunc: (capture: CaptureCancellablePromise) => PromiseLike + innerFunc: (capture: CaptureCancellablePromise) => PromiseLike ): CancellablePromise { - const capturedPromises: CancellablePromise[] = [] + const capturedPromises: CancellablePromise[] = []; - const capture: CaptureCancellablePromise = (promise) => { - capturedPromises.push(promise) - return promise - } + const capture: CaptureCancellablePromise = (promise) => { + capturedPromises.push(promise); + return promise; + }; - function cancel(): void { - capturedPromises.forEach((p) => p.cancel()) - } + function cancel(): void { + capturedPromises.forEach((p) => p.cancel()); + } - return new CancellablePromise(innerFunc(capture), cancel) + return new CancellablePromise(innerFunc(capture), cancel); } diff --git a/tsconfig.json b/tsconfig.json index cc6fef8..c99367f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,29 @@ { - "compilerOptions": { - /* Language and Environment */ - "target": "ES2018", - "lib": ["ES2018"], + "compilerOptions": { + /* Language and Environment */ + "target": "ES2018", + "lib": ["ES2018"], - /* Modules */ - "module": "ESNext", - "moduleResolution": "node", + /* Modules */ + "module": "ESNext", + "moduleResolution": "node", - /* Emit */ - "declaration": true, - "outDir": "./dist", - "noEmit": true, - "noEmitOnError": true, + /* Emit */ + "declaration": true, + "outDir": "./dist", + "noEmit": true, + "noEmitOnError": true, - /* Interop Constraints */ - "isolatedModules": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, + /* Interop Constraints */ + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, - /* Type Checking */ - "strict": true, - "noFallthroughCasesInSwitch": true, + /* Type Checking */ + "strict": true, + "noFallthroughCasesInSwitch": true, - /* Completeness */ - "skipLibCheck": true - } + /* Completeness */ + "skipLibCheck": true + } }