diff --git a/.changeset/brave-carpets-wear.md b/.changeset/brave-carpets-wear.md new file mode 100644 index 000000000..62074ef57 --- /dev/null +++ b/.changeset/brave-carpets-wear.md @@ -0,0 +1,5 @@ +--- +'@solana/promises': minor +--- + +Added `isAbortError(err)` — returns `true` if `err` is an `Error` whose `name` is `'AbortError'`. Use it to distinguish abort rejections from other failures without having to `instanceof`-check every platform-specific error class. diff --git a/packages/promises/README.md b/packages/promises/README.md index 8ae3f840d..9be6b9b8b 100644 --- a/packages/promises/README.md +++ b/packages/promises/README.md @@ -28,6 +28,22 @@ const result = await getAbortablePromise( ); ``` +### `isAbortError(err)` + +Returns `true` if `err` is an `Error` whose `name` is `'AbortError'`. Use this to distinguish abort rejections from other failures without having to `instanceof`-check every platform-specific error class. + +```ts +try { + await getAbortablePromise(doWork(), signal); +} catch (e) { + if (isAbortError(e)) { + // The operation was aborted; don't surface as an error. + return; + } + throw e; +} +``` + ### `safeRace(...promises)` An implementation of `Promise.race` that causes all of the losing promises to settle. This allows them to be released and garbage collected, preventing memory leaks. diff --git a/packages/promises/src/__tests__/abortable-test.browser.ts b/packages/promises/src/__tests__/abortable-test.browser.ts new file mode 100644 index 000000000..19afe9d61 --- /dev/null +++ b/packages/promises/src/__tests__/abortable-test.browser.ts @@ -0,0 +1,12 @@ +import { isAbortError } from '../abortable'; + +describe('isAbortError()', () => { + it('returns `true` for a `DOMException` whose `name` is `AbortError`', () => { + expect(isAbortError(new DOMException('The operation was aborted.', 'AbortError'))).toBe(true); + }); + it('returns `true` for the default `reason` of an `AbortController` aborted without an argument', () => { + const controller = new AbortController(); + controller.abort(); + expect(isAbortError(controller.signal.reason)).toBe(true); + }); +}); diff --git a/packages/promises/src/__tests__/abortable-test.ts b/packages/promises/src/__tests__/abortable-test.ts index ebab5712a..18356f751 100644 --- a/packages/promises/src/__tests__/abortable-test.ts +++ b/packages/promises/src/__tests__/abortable-test.ts @@ -1,4 +1,4 @@ -import { getAbortablePromise } from '../abortable'; +import { getAbortablePromise, isAbortError } from '../abortable'; describe('getAbortablePromise()', () => { let promise: Promise; @@ -74,3 +74,39 @@ describe('getAbortablePromise()', () => { ); }); }); + +describe('isAbortError()', () => { + it('returns `true` for an `Error` whose `name` is `AbortError`', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + expect(isAbortError(err)).toBe(true); + }); + it('returns `true` for a subclass of `Error` whose `name` is `AbortError`', () => { + class CustomError extends Error { + override name = 'AbortError'; + } + expect(isAbortError(new CustomError())).toBe(true); + }); + it('returns `true` for the rejection reason of an aborted fetch-style promise', async () => { + expect.assertions(1); + const controller = new AbortController(); + const promise = getAbortablePromise(new Promise(() => {}), controller.signal); + controller.abort(Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })); + await expect(promise.catch(e => isAbortError(e))).resolves.toBe(true); + }); + it('returns `false` for a regular `Error`', () => { + expect(isAbortError(new Error('nope'))).toBe(false); + }); + it('returns `false` for a `TypeError`', () => { + expect(isAbortError(new TypeError('nope'))).toBe(false); + }); + it('returns `false` for a non-`Error` object whose `name` is `AbortError`', () => { + expect(isAbortError({ name: 'AbortError' })).toBe(false); + }); + it('returns `false` for `undefined`', () => { + expect(isAbortError(undefined)).toBe(false); + }); + it('returns `false` for `null`', () => { + expect(isAbortError(null)).toBe(false); + }); +}); diff --git a/packages/promises/src/abortable.ts b/packages/promises/src/abortable.ts index 9c51b9d40..8ee7b7105 100644 --- a/packages/promises/src/abortable.ts +++ b/packages/promises/src/abortable.ts @@ -1,5 +1,32 @@ import { safeRace } from './race'; +/** + * Returns `true` if the given value is an `Error` whose `name` is `'AbortError'`. + * + * When an {@link AbortSignal} fires without a custom `reason`, or when APIs like `fetch` are + * aborted, they reject with a `DOMException` (or similar `Error` subclass) whose `name` is + * `'AbortError'`. This helper lets callers distinguish abort rejections from other failures + * without having to `instanceof`-check every platform-specific error class. + * + * @example + * ```ts + * try { + * await getAbortablePromise(doWork(), signal); + * } catch (e) { + * if (isAbortError(e)) { + * // The operation was aborted; don't surface as an error. + * return; + * } + * throw e; + * } + * ``` + * + * @see {@link getAbortablePromise} + */ +export function isAbortError(err: unknown): err is Error { + return err instanceof Error && err.name === 'AbortError'; +} + /** * Returns a new promise that will reject if the abort signal fires before the original promise * settles. Resolves or rejects with the value of the original promise otherwise.