diff --git a/cypress/test/functions/utlities/make-cancelable.cy.ts b/cypress/test/functions/utlities/make-cancelable.cy.ts index 1a5f35e..a8397ae 100644 --- a/cypress/test/functions/utlities/make-cancelable.cy.ts +++ b/cypress/test/functions/utlities/make-cancelable.cy.ts @@ -3,51 +3,226 @@ * * (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, 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 () => { - 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; + 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 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; - } catch (error) { - expect(error).to.have.property("isCanceled", true); + throw new Error("Promise should have been rejected"); + } catch (caughtError: unknown) { + expect(caughtError).to.equal(error); } }); - 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)); + 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(); + + 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"); + } + } + }); + + 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(); - const racePromise = win.Promise.race([ - cancelable.promise.then(() => "resolved"), - new win.Promise((resolve) => win.setTimeout(() => resolve("not-resolved"), 200)), - ]); + 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 { - const result = await racePromise; + 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); + } + }); - expect(result).to.equal("not-resolved"); - } catch (error) { - console.log(error); + 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/docs/docs/functions/utils/make-cancelable.mdx b/docs/docs/functions/utils/make-cancelable.mdx index aab8553..c0315b6 100644 --- a/docs/docs/functions/utils/make-cancelable.mdx +++ b/docs/docs/functions/utils/make-cancelable.mdx @@ -1,28 +1,160 @@ --- 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. The function also provides access to the underlying AbortSignal, which can be used to coordinate cancellation across multiple promises or network requests. ## 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. + * @param reason - Optional reason for the cancellation + */ + 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; ``` ### 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); + } + }); + +// Cancel it when needed +cancelable.cancel(); + +// Check if already cancelled +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 + +```tsx +import { makeCancelable } from '@feedzai/js-utilities'; +import { useEffect } from 'react'; + +function MyComponent() { + useEffect(() => { + const cancelable = makeCancelable(fetchData()); + + // 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 + 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 + } +} +``` + +### 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 +}); -// We can cancel it on demand +// Cancelling the original promise will also cancel all operations using its signal cancelable.cancel(); ``` diff --git a/src/functions/utilities/make-cancelable.ts b/src/functions/utilities/make-cancelable.ts index f4b1b76..27ab3e7 100644 --- a/src/functions/utilities/make-cancelable.ts +++ b/src/functions/utilities/make-cancelable.ts @@ -1,66 +1,154 @@ /** * 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"; + /** - * Helper method that wraps a normal Promise and allows it to be cancelled. + * Custom error type for aborted promises */ -export interface MakeCancelablePromise { +export class AbortPromiseError extends DOMException { + constructor(message = "Promise was aborted") { + super(message, "AbortError"); + } +} + +/** + * Helper interface for a cancelable promise that uses AbortController + */ +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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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; } /** - * 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(); + + 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 = () => { + reject(new AbortPromiseError()); + }; - const wrappedPromise = new Promise((resolve, reject) => { + 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cancel(reason?: any) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + }, + isCancelled() { + return controller.signal.aborted; }, + signal: controller.signal, }; }