From 570485538e16733248581815c19b86257c5b1c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dias?= Date: Wed, 4 Jun 2025 09:44:56 +0100 Subject: [PATCH 1/4] feat(make-cancelable): add support for cancelling promises using AbortController --- .../functions/utlities/make-cancelable.cy.ts | 135 ++++++++++++++---- docs/docs/functions/utils/make-cancelable.mdx | 93 +++++++++++- src/functions/utilities/make-cancelable.ts | 131 +++++++++++++---- 3 files changed, 302 insertions(+), 57 deletions(-) diff --git a/cypress/test/functions/utlities/make-cancelable.cy.ts b/cypress/test/functions/utlities/make-cancelable.cy.ts index 1a5f35e..f4f4cb8 100644 --- a/cypress/test/functions/utlities/make-cancelable.cy.ts +++ b/cypress/test/functions/utlities/make-cancelable.cy.ts @@ -3,52 +3,139 @@ * * (c) 2024 Feedzai */ -import { makeCancelable } from "src/functions"; +import { AbortPromiseError, makeCancelable, wait } from "src/functions"; + +async function expectAbort(cancelable: ReturnType) { + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (error) { + expect(error).to.be.instanceOf(AbortPromiseError); + if (error instanceof AbortPromiseError) { + expect(error.message).to.equal("Promise was aborted"); + } + } +} describe("makeCancelable", () => { - it("should return an object with a promise and a cancel function", () => { - const promise = new Promise((resolve) => setTimeout(resolve, 100)); + // Configure Cypress to not fail on unhandled promise rejections + before(() => { + cy.on("uncaught:exception", (err) => { + if (err.name === "AbortError") { + return false; + } + }); + }); + + it("should reject with AbortPromiseError if cancelled just before resolution", async () => { + const promise = wait(10); + const cancelable = makeCancelable(promise); + + setTimeout(() => cancelable.cancel(), 5); + + await expectAbort(cancelable); + }); + + it("should return an object with a promise, cancel function, and isCancelled function", () => { + const promise = wait(25); const cancelable = makeCancelable(promise); expect(cancelable).to.be.an("object"); expect(cancelable.promise).to.be.a("promise"); expect(cancelable.cancel).to.be.a("function"); + expect(cancelable.isCancelled).to.be.a("function"); }); it("should resolve the promise if not cancelled", async () => { - const promise = new Promise((resolve) => setTimeout(resolve, 100)); + const value = "test value"; + const promise = new Promise((resolve) => setTimeout(() => resolve(value), 25)); const cancelable = makeCancelable(promise); const result = await cancelable.promise; - expect(result).to.be.undefined; // Or any other expected resolved value + expect(result).to.equal(value); }); - it("should reject the promise with { isCanceled: true } if cancelled", async () => { - const promise = new Promise((resolve) => setTimeout(resolve, 100)); + it("should reject with AbortPromiseError when cancelled", async () => { + const promise = wait(25); const cancelable = makeCancelable(promise); cancelable.cancel(); + try { await cancelable.promise; - } catch (error) { - expect(error).to.have.property("isCanceled", true); + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + if (error instanceof AbortPromiseError) { + expect(error.message).to.equal("Promise was aborted"); + } } }); - it("should not resolve or reject the promise after being cancelled", async () => { - cy.window().then(async (win) => { - const promise = new win.Promise((resolve) => win.setTimeout(resolve, 100)); - const cancelable = makeCancelable(promise); - cancelable.cancel(); - const racePromise = win.Promise.race([ - cancelable.promise.then(() => "resolved"), - new win.Promise((resolve) => win.setTimeout(() => resolve("not-resolved"), 200)), - ]); + it("should handle rejection from the original promise", async () => { + const error = new Error("Original promise error"); + const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25)); + const cancelable = makeCancelable(promise); + + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (caughtError: unknown) { + expect(caughtError).to.equal(error); + } + }); - try { - const result = await racePromise; + it("should not reject with original error if cancelled", async () => { + const error = new Error("Original promise error"); + const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25)); + const cancelable = makeCancelable(promise); + cancelable.cancel(); - expect(result).to.equal("not-resolved"); - } catch (error) { - console.log(error); + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (caughtError: unknown) { + expect(caughtError).to.be.instanceOf(AbortPromiseError); + if (caughtError instanceof AbortPromiseError) { + expect(caughtError.message).to.equal("Promise was aborted"); } - }); + } + }); + + it("should handle multiple cancel calls", async () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + + cancelable.cancel(); + cancelable.cancel(); // Second call should be ignored + + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + } + }); + + it("should correctly report cancellation state", async () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + + expect(cancelable.isCancelled()).to.be.false; + cancelable.cancel(); + expect(cancelable.isCancelled()).to.be.true; + }); + + it("should handle abort error message", async () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + cancelable.cancel(); + + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + if (error instanceof AbortPromiseError) { + expect(error.message).to.equal("Promise was aborted"); + } + } }); }); diff --git a/docs/docs/functions/utils/make-cancelable.mdx b/docs/docs/functions/utils/make-cancelable.mdx index aab8553..fc9cb06 100644 --- a/docs/docs/functions/utils/make-cancelable.mdx +++ b/docs/docs/functions/utils/make-cancelable.mdx @@ -1,28 +1,107 @@ --- title: makeCancelable --- -Wraps a native Promise and allows it to be cancelled. +Wraps a native Promise and allows it to be cancelled using AbortController. This is useful for cancelling long-running operations or preventing memory leaks when a component unmounts before an async operation completes. ## API ```typescript -function makeCancelable(promise: Promise): MakeCancelablePromise; +interface MakeCancelablePromise { + /** + * The wrapped promise that can be aborted + */ + promise: Promise; + /** + * Aborts the promise execution. Safe to call multiple times - subsequent calls will be ignored if already cancelled. + */ + cancel: () => void; + /** + * Checks whether the promise has been cancelled + */ + isCancelled: () => boolean; +} + +function makeCancelable(promise: Promise): MakeCancelablePromise; ``` ### Usage ```tsx -import { wait } from '@feedzai/js-utilities'; +import { makeCancelable, wait } from '@feedzai/js-utilities'; // A Promise that resolves after 1 second const somePromise = wait(1000); -// Can also be made cancellable by wrapping it +// Make it cancelable const cancelable = makeCancelable(somePromise); -// So that when we execute said wrapped promise... -cancelable.promise.then(console.log).catch(({ isCanceled }) => console.error('isCanceled', isCanceled)); +// Execute the wrapped promise +cancelable.promise + .then(console.log) + .catch(error => { + if (error instanceof AbortPromiseError) { + console.log('Promise was cancelled'); + } else { + console.error('Other error:', error); + } + }); -// We can cancel it on demand +// Cancel it when needed cancelable.cancel(); + +// Check if already cancelled +if (cancelable.isCancelled()) { + console.log('Promise was already cancelled'); +} +``` + +### React Example + +```tsx +import { makeCancelable } from '@feedzai/js-utilities'; +import { useEffect } from 'react'; + +function MyComponent() { + useEffect(() => { + const cancelable = makeCancelable(fetchData()); + + cancelable.promise + .then(setData) + .catch(error => { + if (error instanceof AbortPromiseError) { + // Handle cancellation + console.log('Data fetch was cancelled'); + } else { + // Handle other errors + console.error('Error fetching data:', error); + } + }); + + // Cleanup on unmount + return () => cancelable.cancel(); + }, []); + + return
...
; +} +``` + +### Error Handling + +When a promise is cancelled, it rejects with an `AbortPromiseError`. This error extends `DOMException` and has the following properties: + +- `name`: "AbortError" +- `message`: "Promise was aborted" + +You can check for cancellation by using `instanceof`: + +```typescript +try { + await cancelable.promise; +} catch (error) { + if (error instanceof AbortPromiseError) { + // Handle cancellation + } else { + // Handle other errors + } +} ``` diff --git a/src/functions/utilities/make-cancelable.ts b/src/functions/utilities/make-cancelable.ts index f4b1b76..2878bd8 100644 --- a/src/functions/utilities/make-cancelable.ts +++ b/src/functions/utilities/make-cancelable.ts @@ -1,66 +1,145 @@ /** * Please refer to the terms of the license agreement in the root of the project * - * (c) 2024 Feedzai + * (c) 2025 Feedzai */ +import { off, on } from "../events"; + +/** + * Custom error type for aborted promises + */ +export class AbortPromiseError extends DOMException { + constructor(message = "Promise was aborted") { + super(message, "AbortError"); + } +} + /** - * Helper method that wraps a normal Promise and allows it to be cancelled. + * Helper interface for a cancelable promise that uses AbortController */ -export interface MakeCancelablePromise { +export interface MakeCancelablePromise { /** - * Holds the promise itself + * The wrapped promise that can be aborted */ - promise: Promise; - + promise: Promise; /** - * Rejects the promise by cancelling it + * Aborts the promise execution. Safe to call multiple times - subsequent calls will be ignored if already cancelled. */ cancel: () => void; + /** + * Checks whether the promise has been cancelled + * @returns {boolean} True if the promise has been cancelled, false otherwise + */ + isCancelled: () => boolean; } /** - * Helper method that wraps a normal Promise and allows it to be cancelled. + * Wraps a Promise to make it cancelable using AbortController. + * This is useful for cancelling long-running operations or preventing memory leaks + * when a component unmounts before an async operation completes. * - * @example + * @template T - The type of the value that the promise resolves to + * @param promise - The promise to make cancelable + * @returns {MakeCancelablePromise} An object containing: + * - promise: The wrapped promise that can be aborted + * - cancel: Function to abort the promise + * - isCancelled: Function to check if the promise has been cancelled * - * ```js - * import { wait } from "@joaomtmdias/js-utilities"; + * @example + * ```ts + * import { wait } from "@feedzai/js-utilities"; * - * // A Promise that resolves after 1 second + * // Create a Promise that resolves after 1 second * const somePromise = wait(1000); * - * // Can also be made cancellable by wrapping it + * // Make it cancelable * const cancelable = makeCancelable(somePromise); * - * // So that when we execute said wrapped promise... + * // Execute the wrapped promise * cancelable.promise - * .then(console.log) - * .catch(({ isCanceled }) => console.error('isCanceled', isCanceled)); + * .then(console.log) + * .catch(error => { + * if (error instanceof AbortPromiseError) { + * console.log('Promise was cancelled'); + * } else { + * console.error('Other error:', error); + * } + * }); * - * // We can cancel it on demand + * // Cancel it when needed * cancelable.cancel(); * ``` + * + * @example + * ```tsx + * import { makeCancelable } from "@feedzai/js-utilities"; + * import { useEffect } from "react"; + * + * function MyComponent() { + * useEffect(() => { + * const cancelable = makeCancelable(fetchData()); + * + * cancelable.promise + * .then(setData) + * .catch(handleError); + * + * // Cleanup on unmount + * return () => cancelable.cancel(); + * }, []); + * + * return
...
; + * } + * ``` */ -export function makeCancelable( - promise: Promise -): MakeCancelablePromise { - let hasCanceled_ = false; +export function makeCancelable(promise: Promise): MakeCancelablePromise { + const controller = new AbortController(); + let isCancelled = false; - const wrappedPromise = new Promise((resolve, reject) => { + const wrappedPromise = new Promise((resolve, reject) => { + // Early return if already cancelled + if (controller.signal.aborted) { + reject(new AbortPromiseError()); + return; + } + + // Add abort signal listener + const abortHandler = () => { + isCancelled = true; + reject(new AbortPromiseError()); + }; + + on(controller.signal, "abort", abortHandler); + + // Execute the original promise promise - .then((val) => { - return hasCanceled_ ? reject({ isCanceled: true }) : resolve(val); + .then((value) => { + // Only resolve if not aborted + if (!controller.signal.aborted) { + resolve(value); + } }) .catch((error) => { - return hasCanceled_ ? reject({ isCanceled: true }) : reject(error); + // Only reject if not aborted + if (!controller.signal.aborted) { + reject(error); + } + }) + .finally(() => { + // Clean up the abort listener + off(controller.signal, "abort", abortHandler); }); }); return { promise: wrappedPromise, cancel() { - hasCanceled_ = true; + if (!isCancelled) { + controller.abort(); + } + }, + isCancelled() { + return isCancelled; }, }; } From dc930f9778897c971defc8f150875002cf88aced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dias?= Date: Wed, 4 Jun 2025 10:49:05 +0100 Subject: [PATCH 2/4] fix(make-cancelable): removed isCancelled and added abort reasons --- src/functions/utilities/make-cancelable.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/functions/utilities/make-cancelable.ts b/src/functions/utilities/make-cancelable.ts index 2878bd8..539d610 100644 --- a/src/functions/utilities/make-cancelable.ts +++ b/src/functions/utilities/make-cancelable.ts @@ -23,13 +23,15 @@ export interface MakeCancelablePromise { * The wrapped promise that can be aborted */ promise: Promise; + /** * Aborts the promise execution. Safe to call multiple times - subsequent calls will be ignored if already cancelled. */ - cancel: () => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cancel: (reason?: any) => void; + /** * Checks whether the promise has been cancelled - * @returns {boolean} True if the promise has been cancelled, false otherwise */ isCancelled: () => boolean; } @@ -94,7 +96,6 @@ export interface MakeCancelablePromise { */ export function makeCancelable(promise: Promise): MakeCancelablePromise { const controller = new AbortController(); - let isCancelled = false; const wrappedPromise = new Promise((resolve, reject) => { // Early return if already cancelled @@ -105,7 +106,6 @@ export function makeCancelable(promise: Promise): MakeCancelable // Add abort signal listener const abortHandler = () => { - isCancelled = true; reject(new AbortPromiseError()); }; @@ -133,13 +133,14 @@ export function makeCancelable(promise: Promise): MakeCancelable return { promise: wrappedPromise, - cancel() { - if (!isCancelled) { - controller.abort(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cancel(reason?: any) { + if (!controller.signal.aborted) { + controller.abort(reason); } }, isCancelled() { - return isCancelled; + return controller.signal.aborted; }, }; } From b50a55bfb57392b8c4c28a4a927712bf2af71c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dias?= Date: Wed, 4 Jun 2025 10:53:36 +0100 Subject: [PATCH 3/4] feat(make-cancelable): added signal as part of the returning object --- .../functions/utlities/make-cancelable.cy.ts | 90 ++++++++++++++++++- src/functions/utilities/make-cancelable.ts | 8 ++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/cypress/test/functions/utlities/make-cancelable.cy.ts b/cypress/test/functions/utlities/make-cancelable.cy.ts index f4f4cb8..a8397ae 100644 --- a/cypress/test/functions/utlities/make-cancelable.cy.ts +++ b/cypress/test/functions/utlities/make-cancelable.cy.ts @@ -36,13 +36,14 @@ describe("makeCancelable", () => { await expectAbort(cancelable); }); - it("should return an object with a promise, cancel function, and isCancelled function", () => { + it("should return an object with a promise, cancel function, isCancelled function, and signal", () => { const promise = wait(25); const cancelable = makeCancelable(promise); expect(cancelable).to.be.an("object"); expect(cancelable.promise).to.be.a("promise"); expect(cancelable.cancel).to.be.a("function"); expect(cancelable.isCancelled).to.be.a("function"); + expect(cancelable.signal).to.be.an("AbortSignal"); }); it("should resolve the promise if not cancelled", async () => { @@ -138,4 +139,91 @@ describe("makeCancelable", () => { } } }); + + describe("signal property", () => { + it("should be an AbortSignal instance", () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + expect(cancelable.signal).to.be.instanceOf(AbortSignal); + }); + + it("should reflect cancellation state", () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + expect(cancelable.signal.aborted).to.be.false; + cancelable.cancel(); + expect(cancelable.signal.aborted).to.be.true; + }); + + it("should be usable with fetch", async () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + + // Simulate a fetch request that would use the signal + const fetchPromise = new Promise((resolve, reject) => { + cancelable.signal.addEventListener("abort", () => { + reject(new AbortPromiseError()); + }); + setTimeout(resolve, 50); + }); + + cancelable.cancel(); + try { + await fetchPromise; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + } + }); + + it("should be usable with multiple promises", async () => { + const promise1 = wait(25); + const cancelable1 = makeCancelable(promise1); + + // Simulate multiple operations using the same signal + const operation1 = new Promise((resolve, reject) => { + cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError())); + setTimeout(resolve, 50); + }); + + const operation2 = new Promise((resolve, reject) => { + cancelable1.signal.addEventListener("abort", () => reject(new AbortPromiseError())); + setTimeout(resolve, 50); + }); + + cancelable1.cancel(); + + try { + await operation1; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + } + + try { + await operation2; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + } + }); + + it("should handle custom abort reason", async () => { + const promise = wait(25); + const cancelable = makeCancelable(promise); + const reason = "Custom abort reason"; + + cancelable.cancel(reason); + + try { + await cancelable.promise; + throw new Error("Promise should have been rejected"); + } catch (error: unknown) { + expect(error).to.be.instanceOf(AbortPromiseError); + if (error instanceof AbortPromiseError) { + expect(error.message).to.equal("Promise was aborted"); + } + } + }); + }); }); diff --git a/src/functions/utilities/make-cancelable.ts b/src/functions/utilities/make-cancelable.ts index 539d610..27ab3e7 100644 --- a/src/functions/utilities/make-cancelable.ts +++ b/src/functions/utilities/make-cancelable.ts @@ -34,6 +34,13 @@ export interface MakeCancelablePromise { * Checks whether the promise has been cancelled */ isCancelled: () => boolean; + + /** + * The AbortSignal object that can be used to check if the promise has been cancelled. + * This signal can be used to coordinate cancellation across multiple promises or network requests + * by passing it to other abortable operations that should be cancelled together. + */ + signal: AbortSignal; } /** @@ -142,5 +149,6 @@ export function makeCancelable(promise: Promise): MakeCancelable isCancelled() { return controller.signal.aborted; }, + signal: controller.signal, }; } From 2a7505e2a3f0924a24735cb7a1dc237cfffe2430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dias?= Date: Wed, 4 Jun 2025 11:00:50 +0100 Subject: [PATCH 4/4] docs(make-cancelable): updated documentation --- docs/docs/functions/utils/make-cancelable.mdx | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/docs/functions/utils/make-cancelable.mdx b/docs/docs/functions/utils/make-cancelable.mdx index fc9cb06..c0315b6 100644 --- a/docs/docs/functions/utils/make-cancelable.mdx +++ b/docs/docs/functions/utils/make-cancelable.mdx @@ -1,7 +1,7 @@ --- title: makeCancelable --- -Wraps a native Promise and allows it to be cancelled using AbortController. This is useful for cancelling long-running operations or preventing memory leaks when a component unmounts before an async operation completes. +Wraps a native Promise and allows it to be cancelled using AbortController. This is useful for cancelling long-running operations or preventing memory leaks when a component unmounts before an async operation completes. The function also provides access to the underlying AbortSignal, which can be used to coordinate cancellation across multiple promises or network requests. ## API @@ -11,14 +11,24 @@ interface MakeCancelablePromise { * The wrapped promise that can be aborted */ promise: Promise; + /** * Aborts the promise execution. Safe to call multiple times - subsequent calls will be ignored if already cancelled. + * @param reason - Optional reason for the cancellation */ - cancel: () => void; + cancel: (reason?: any) => void; + /** * Checks whether the promise has been cancelled */ isCancelled: () => boolean; + + /** + * The AbortSignal object that can be used to check if the promise has been cancelled. + * This signal can be used to coordinate cancellation across multiple promises or network requests + * by passing it to other abortable operations that should be cancelled together. + */ + signal: AbortSignal; } function makeCancelable(promise: Promise): MakeCancelablePromise; @@ -53,6 +63,15 @@ cancelable.cancel(); if (cancelable.isCancelled()) { console.log('Promise was already cancelled'); } + +// Use the signal with other abortable operations +fetch('/api/data', { signal: cancelable.signal }) + .then(response => response.json()) + .catch(error => { + if (error instanceof AbortPromiseError) { + console.log('Fetch was cancelled'); + } + }); ``` ### React Example @@ -65,8 +84,16 @@ function MyComponent() { useEffect(() => { const cancelable = makeCancelable(fetchData()); - cancelable.promise - .then(setData) + // Use the signal with multiple operations + const fetchUser = fetch('/api/user', { signal: cancelable.signal }); + const fetchSettings = fetch('/api/settings', { signal: cancelable.signal }); + + Promise.all([cancelable.promise, fetchUser, fetchSettings]) + .then(([data, user, settings]) => { + setData(data); + setUser(user); + setSettings(settings); + }) .catch(error => { if (error instanceof AbortPromiseError) { // Handle cancellation @@ -105,3 +132,29 @@ try { } } ``` + +### Coordinating Multiple Operations + +The `signal` property can be used to coordinate cancellation across multiple operations. This is particularly useful when you need to cancel multiple related operations together: + +```typescript +const cancelable = makeCancelable(fetchData()); + +// Use the same signal for multiple operations +const operation1 = new Promise((resolve, reject) => { + cancelable.signal.addEventListener('abort', () => { + reject(new AbortPromiseError()); + }); + // ... operation logic +}); + +const operation2 = new Promise((resolve, reject) => { + cancelable.signal.addEventListener('abort', () => { + reject(new AbortPromiseError()); + }); + // ... operation logic +}); + +// Cancelling the original promise will also cancel all operations using its signal +cancelable.cancel(); +```