Skip to content

Commit 5704855

Browse files
author
João Dias
committed
feat(make-cancelable): add support for cancelling promises using AbortController
1 parent a045b7d commit 5704855

File tree

3 files changed

+302
-57
lines changed

3 files changed

+302
-57
lines changed

cypress/test/functions/utlities/make-cancelable.cy.ts

Lines changed: 111 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,139 @@
33
*
44
* (c) 2024 Feedzai
55
*/
6-
import { makeCancelable } from "src/functions";
6+
import { AbortPromiseError, makeCancelable, wait } from "src/functions";
7+
8+
async function expectAbort(cancelable: ReturnType<typeof makeCancelable>) {
9+
try {
10+
await cancelable.promise;
11+
throw new Error("Promise should have been rejected");
12+
} catch (error) {
13+
expect(error).to.be.instanceOf(AbortPromiseError);
14+
if (error instanceof AbortPromiseError) {
15+
expect(error.message).to.equal("Promise was aborted");
16+
}
17+
}
18+
}
719

820
describe("makeCancelable", () => {
9-
it("should return an object with a promise and a cancel function", () => {
10-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
21+
// Configure Cypress to not fail on unhandled promise rejections
22+
before(() => {
23+
cy.on("uncaught:exception", (err) => {
24+
if (err.name === "AbortError") {
25+
return false;
26+
}
27+
});
28+
});
29+
30+
it("should reject with AbortPromiseError if cancelled just before resolution", async () => {
31+
const promise = wait(10);
32+
const cancelable = makeCancelable(promise);
33+
34+
setTimeout(() => cancelable.cancel(), 5);
35+
36+
await expectAbort(cancelable);
37+
});
38+
39+
it("should return an object with a promise, cancel function, and isCancelled function", () => {
40+
const promise = wait(25);
1141
const cancelable = makeCancelable(promise);
1242
expect(cancelable).to.be.an("object");
1343
expect(cancelable.promise).to.be.a("promise");
1444
expect(cancelable.cancel).to.be.a("function");
45+
expect(cancelable.isCancelled).to.be.a("function");
1546
});
1647

1748
it("should resolve the promise if not cancelled", async () => {
18-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
49+
const value = "test value";
50+
const promise = new Promise((resolve) => setTimeout(() => resolve(value), 25));
1951
const cancelable = makeCancelable(promise);
2052
const result = await cancelable.promise;
21-
expect(result).to.be.undefined; // Or any other expected resolved value
53+
expect(result).to.equal(value);
2254
});
2355

24-
it("should reject the promise with { isCanceled: true } if cancelled", async () => {
25-
const promise = new Promise((resolve) => setTimeout(resolve, 100));
56+
it("should reject with AbortPromiseError when cancelled", async () => {
57+
const promise = wait(25);
2658
const cancelable = makeCancelable(promise);
2759
cancelable.cancel();
60+
2861
try {
2962
await cancelable.promise;
30-
} catch (error) {
31-
expect(error).to.have.property("isCanceled", true);
63+
throw new Error("Promise should have been rejected");
64+
} catch (error: unknown) {
65+
expect(error).to.be.instanceOf(AbortPromiseError);
66+
if (error instanceof AbortPromiseError) {
67+
expect(error.message).to.equal("Promise was aborted");
68+
}
3269
}
3370
});
3471

35-
it("should not resolve or reject the promise after being cancelled", async () => {
36-
cy.window().then(async (win) => {
37-
const promise = new win.Promise((resolve) => win.setTimeout(resolve, 100));
38-
const cancelable = makeCancelable(promise);
39-
cancelable.cancel();
40-
const racePromise = win.Promise.race([
41-
cancelable.promise.then(() => "resolved"),
42-
new win.Promise((resolve) => win.setTimeout(() => resolve("not-resolved"), 200)),
43-
]);
72+
it("should handle rejection from the original promise", async () => {
73+
const error = new Error("Original promise error");
74+
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
75+
const cancelable = makeCancelable(promise);
76+
77+
try {
78+
await cancelable.promise;
79+
throw new Error("Promise should have been rejected");
80+
} catch (caughtError: unknown) {
81+
expect(caughtError).to.equal(error);
82+
}
83+
});
4484

45-
try {
46-
const result = await racePromise;
85+
it("should not reject with original error if cancelled", async () => {
86+
const error = new Error("Original promise error");
87+
const promise = new Promise((_, reject) => setTimeout(() => reject(error), 25));
88+
const cancelable = makeCancelable(promise);
89+
cancelable.cancel();
4790

48-
expect(result).to.equal("not-resolved");
49-
} catch (error) {
50-
console.log(error);
91+
try {
92+
await cancelable.promise;
93+
throw new Error("Promise should have been rejected");
94+
} catch (caughtError: unknown) {
95+
expect(caughtError).to.be.instanceOf(AbortPromiseError);
96+
if (caughtError instanceof AbortPromiseError) {
97+
expect(caughtError.message).to.equal("Promise was aborted");
5198
}
52-
});
99+
}
100+
});
101+
102+
it("should handle multiple cancel calls", async () => {
103+
const promise = wait(25);
104+
const cancelable = makeCancelable(promise);
105+
106+
cancelable.cancel();
107+
cancelable.cancel(); // Second call should be ignored
108+
109+
try {
110+
await cancelable.promise;
111+
throw new Error("Promise should have been rejected");
112+
} catch (error: unknown) {
113+
expect(error).to.be.instanceOf(AbortPromiseError);
114+
}
115+
});
116+
117+
it("should correctly report cancellation state", async () => {
118+
const promise = wait(25);
119+
const cancelable = makeCancelable(promise);
120+
121+
expect(cancelable.isCancelled()).to.be.false;
122+
cancelable.cancel();
123+
expect(cancelable.isCancelled()).to.be.true;
124+
});
125+
126+
it("should handle abort error message", async () => {
127+
const promise = wait(25);
128+
const cancelable = makeCancelable(promise);
129+
cancelable.cancel();
130+
131+
try {
132+
await cancelable.promise;
133+
throw new Error("Promise should have been rejected");
134+
} catch (error: unknown) {
135+
expect(error).to.be.instanceOf(AbortPromiseError);
136+
if (error instanceof AbortPromiseError) {
137+
expect(error.message).to.equal("Promise was aborted");
138+
}
139+
}
53140
});
54141
});
Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,107 @@
11
---
22
title: makeCancelable
33
---
4-
Wraps a native Promise and allows it to be cancelled.
4+
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.
55

66
## API
77

88
```typescript
9-
function makeCancelable<GenericPromiseValue = unknown>(promise: Promise<GenericPromiseValue>): MakeCancelablePromise<GenericPromiseValue>;
9+
interface MakeCancelablePromise<T = unknown> {
10+
/**
11+
* The wrapped promise that can be aborted
12+
*/
13+
promise: Promise<T>;
14+
/**
15+
* Aborts the promise execution. Safe to call multiple times - subsequent calls will be ignored if already cancelled.
16+
*/
17+
cancel: () => void;
18+
/**
19+
* Checks whether the promise has been cancelled
20+
*/
21+
isCancelled: () => boolean;
22+
}
23+
24+
function makeCancelable<T = unknown>(promise: Promise<T>): MakeCancelablePromise<T>;
1025
```
1126

1227
### Usage
1328

1429
```tsx
15-
import { wait } from '@feedzai/js-utilities';
30+
import { makeCancelable, wait } from '@feedzai/js-utilities';
1631

1732
// A Promise that resolves after 1 second
1833
const somePromise = wait(1000);
1934

20-
// Can also be made cancellable by wrapping it
35+
// Make it cancelable
2136
const cancelable = makeCancelable(somePromise);
2237

23-
// So that when we execute said wrapped promise...
24-
cancelable.promise.then(console.log).catch(({ isCanceled }) => console.error('isCanceled', isCanceled));
38+
// Execute the wrapped promise
39+
cancelable.promise
40+
.then(console.log)
41+
.catch(error => {
42+
if (error instanceof AbortPromiseError) {
43+
console.log('Promise was cancelled');
44+
} else {
45+
console.error('Other error:', error);
46+
}
47+
});
2548

26-
// We can cancel it on demand
49+
// Cancel it when needed
2750
cancelable.cancel();
51+
52+
// Check if already cancelled
53+
if (cancelable.isCancelled()) {
54+
console.log('Promise was already cancelled');
55+
}
56+
```
57+
58+
### React Example
59+
60+
```tsx
61+
import { makeCancelable } from '@feedzai/js-utilities';
62+
import { useEffect } from 'react';
63+
64+
function MyComponent() {
65+
useEffect(() => {
66+
const cancelable = makeCancelable(fetchData());
67+
68+
cancelable.promise
69+
.then(setData)
70+
.catch(error => {
71+
if (error instanceof AbortPromiseError) {
72+
// Handle cancellation
73+
console.log('Data fetch was cancelled');
74+
} else {
75+
// Handle other errors
76+
console.error('Error fetching data:', error);
77+
}
78+
});
79+
80+
// Cleanup on unmount
81+
return () => cancelable.cancel();
82+
}, []);
83+
84+
return <div>...</div>;
85+
}
86+
```
87+
88+
### Error Handling
89+
90+
When a promise is cancelled, it rejects with an `AbortPromiseError`. This error extends `DOMException` and has the following properties:
91+
92+
- `name`: "AbortError"
93+
- `message`: "Promise was aborted"
94+
95+
You can check for cancellation by using `instanceof`:
96+
97+
```typescript
98+
try {
99+
await cancelable.promise;
100+
} catch (error) {
101+
if (error instanceof AbortPromiseError) {
102+
// Handle cancellation
103+
} else {
104+
// Handle other errors
105+
}
106+
}
28107
```

0 commit comments

Comments
 (0)