diff --git a/packages/react/src/__tests__/abortable-promise-test.ts b/packages/react/src/__tests__/abortable-promise-test.ts new file mode 100644 index 000000000000..c76bb7c2634f --- /dev/null +++ b/packages/react/src/__tests__/abortable-promise-test.ts @@ -0,0 +1,74 @@ +import { getAbortablePromise } from '../abortable-promise'; + +describe('getAbortablePromise()', () => { + let promise: Promise; + let resolve: (value: unknown) => void; + let reject: (reason?: unknown) => void; + beforeEach(() => { + promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + }); + it('returns the original promise when called with no `AbortSignal`', () => { + expect(getAbortablePromise(promise)).toBe(promise); + }); + it('rejects with the `reason` when passed an already-aborted signal with a pending promise', async () => { + expect.assertions(1); + const signal = AbortSignal.abort('o no'); + await expect(getAbortablePromise(promise, signal)).rejects.toBe('o no'); + }); + it('rejects with the `reason` when passed an already-aborted signal and an already-resolved promise', async () => { + expect.assertions(1); + const signal = AbortSignal.abort('o no'); + resolve(123); + await expect(getAbortablePromise(promise, signal)).rejects.toBe('o no'); + }); + it('rejects with the `reason` when passed an already-aborted signal and an already-rejected promise', async () => { + expect.assertions(1); + const signal = AbortSignal.abort('o no'); + reject('mais non'); + await expect(getAbortablePromise(promise, signal)).rejects.toBe('o no'); + }); + it('rejects with the `reason` when the signal aborts before the promise settles', async () => { + expect.assertions(2); + const controller = new AbortController(); + const abortablePromise = getAbortablePromise(promise, controller.signal); + await expect(Promise.race(['pending', abortablePromise])).resolves.toBe('pending'); + controller.abort('o no'); + await expect(abortablePromise).rejects.toBe('o no'); + }); + it('rejects with the promise rejection when passed an already-rejected promise and a not-yet-aborted signal', async () => { + expect.assertions(1); + const signal = new AbortController().signal; + reject('mais non'); + await expect(getAbortablePromise(promise, signal)).rejects.toBe('mais non'); + }); + it('rejects with the promise rejection when the promise rejects before the signal aborts', async () => { + expect.assertions(2); + const signal = new AbortController().signal; + const abortablePromise = getAbortablePromise(promise, signal); + await expect(Promise.race(['pending', abortablePromise])).resolves.toBe('pending'); + reject('mais non'); + await expect(abortablePromise).rejects.toBe('mais non'); + }); + it('resolves with the promise value when passed an already-resolved promise and a not-yet-aborted signal', async () => { + expect.assertions(1); + const signal = new AbortController().signal; + resolve(123); + await expect(getAbortablePromise(promise, signal)).resolves.toBe(123); + }); + it('resolves with the promise value when the promise resolves before the signal aborts', async () => { + expect.assertions(2); + const signal = new AbortController().signal; + const abortablePromise = getAbortablePromise(promise, signal); + await expect(Promise.race(['pending', abortablePromise])).resolves.toBe('pending'); + resolve(123); + await expect(abortablePromise).resolves.toBe(123); + }); + it('pends when neither the promise has resolved nor the signal aborted', async () => { + expect.assertions(1); + const signal = new AbortController().signal; + await expect(Promise.race(['pending', getAbortablePromise(promise, signal)])).resolves.toBe('pending'); + }); +}); diff --git a/packages/react/src/abortable-promise.ts b/packages/react/src/abortable-promise.ts new file mode 100644 index 000000000000..d8b90e0e6019 --- /dev/null +++ b/packages/react/src/abortable-promise.ts @@ -0,0 +1,21 @@ +export function getAbortablePromise(promise: Promise, abortSignal?: AbortSignal): Promise { + if (!abortSignal) { + return promise; + } else { + return Promise.race([ + // This promise only ever rejects if the signal is aborted. Otherwise it idles forever. + // It's important that this come before the input promise; in the event of an abort, we + // want to throw even if the input promise's result is ready + new Promise((_, reject) => { + if (abortSignal.aborted) { + reject(abortSignal.reason); + } else { + abortSignal.addEventListener('abort', function () { + reject(this.reason); + }); + } + }), + promise, + ]); + } +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index 0b0cef7b4ed9..2b4df57b43f1 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "jsx": "react", - "lib": ["DOM", "ES2015"] + "lib": ["DOM", "ES2015", "ESNext.Promise"] }, "display": "@solana/react", "extends": "../tsconfig/base.json",