Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-carpets-wear.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/promises/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/promises/src/__tests__/abortable-test.browser.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 37 additions & 1 deletion packages/promises/src/__tests__/abortable-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAbortablePromise } from '../abortable';
import { getAbortablePromise, isAbortError } from '../abortable';

describe('getAbortablePromise()', () => {
let promise: Promise<unknown>;
Expand Down Expand Up @@ -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`', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: since the docblock specifically calls out DOMException as the real-world aborted-fetch rejection shape, consider adding a case that uses the real thing:

it('returns `true` for a `DOMException` whose `name` is `AbortError`', () => {
    expect(isAbortError(new DOMException('The operation was aborted.', 'AbortError'))).toBe(true);
});

This exercises the instanceof Error branch against the actual platform class rather than a synthesized Error with a patched name, and guards against any future environment where DOMException stops extending Error. Non-blocking.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea - had to add this as a browser only test because of a limitation of Jest's node env.

expect(isAbortError(undefined)).toBe(false);
});
it('returns `false` for `null`', () => {
expect(isAbortError(null)).toBe(false);
});
});
27 changes: 27 additions & 0 deletions packages/promises/src/abortable.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading