Skip to content

Commit 189b2c2

Browse files
authored
feat: optimize AbortError (#14)
1 parent dc6c62c commit 189b2c2

11 files changed

Lines changed: 489 additions & 52 deletions

src/AbortError.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,45 @@ it('catchAbortError', () => {
3434
expect(() => catchAbortError(new AbortError())).not.toThrow();
3535
expect(() => catchAbortError(new Error())).toThrow();
3636
});
37+
38+
it('AbortError with custom message', () => {
39+
const error = new AbortError('Custom abort message');
40+
expect(error.message).toBe('Custom abort message');
41+
expect(error.name).toBe('AbortError');
42+
});
43+
44+
it('AbortError default message', () => {
45+
const error = new AbortError();
46+
expect(error.message).toBe('The operation has been aborted');
47+
expect(error.name).toBe('AbortError');
48+
});
49+
50+
it('AbortError with captureStackTrace disabled', () => {
51+
const error = new AbortError('Test message', false);
52+
expect(error.message).toBe('Test message');
53+
expect(error.name).toBe('AbortError');
54+
expect(error.stack).toBe('');
55+
56+
expect(isAbortError(error)).toBe(true);
57+
expect(error).toBeInstanceOf(Error);
58+
expect(error).toBeInstanceOf(AbortError);
59+
});
60+
61+
it('AbortError with captureStackTrace enabled', () => {
62+
const error = new AbortError('Test message', true);
63+
expect(error.message).toBe('Test message');
64+
expect(error.name).toBe('AbortError');
65+
expect(error.stack).toContain('src/AbortError.test.ts');
66+
67+
expect(isAbortError(error)).toBe(true);
68+
expect(error).toBeInstanceOf(Error);
69+
expect(error).toBeInstanceOf(AbortError);
70+
});
71+
72+
it('throwIfAborted with custom reason', () => {
73+
const abortController = new AbortController();
74+
const customReason = new Error('Custom reason');
75+
abortController.abort(customReason);
76+
77+
expect(() => throwIfAborted(abortController.signal)).toThrow(AbortError);
78+
});

src/AbortError.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@
44
* **Warning**: do not use `instanceof` with this class. Instead, use
55
* `isAbortError` function.
66
*/
7-
export class AbortError extends Error {
8-
constructor() {
9-
super('The operation has been aborted');
7+
export class AbortError implements Error {
8+
name: 'AbortError' = 'AbortError';
9+
stack: string = '';
1010

11-
this.message = 'The operation has been aborted';
11+
constructor(
12+
public message = 'The operation has been aborted',
13+
captureStackTrace = true,
14+
) {
15+
if (captureStackTrace) {
16+
Error.captureStackTrace?.(this, this.constructor);
17+
}
1218

13-
this.name = 'AbortError';
19+
Object.setPrototypeOf(this, Error.prototype);
20+
}
1421

15-
if (typeof Error.captureStackTrace === 'function') {
16-
Error.captureStackTrace(this, this.constructor);
17-
}
22+
static [Symbol.hasInstance](instance: unknown) {
23+
return isAbortError(instance);
1824
}
1925
}
2026

src/all.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import defer from 'defer-promise';
22
import expect from 'expect';
33
import {AbortError} from './AbortError';
44
import {all} from './all';
5-
import {spyOn} from './testUtils/spy';
5+
import {createSpy, spyOn} from './testUtils/spy';
66
import {nextTick} from './utils/nextTick';
77

88
it('external abort', async () => {
@@ -208,3 +208,94 @@ it('empty', async () => {
208208
expect(addEventListenerSpy.callCount).toBe(0);
209209
expect(removeEventListenerSpy.callCount).toBe(0);
210210
});
211+
212+
it('abort with custom reason', async () => {
213+
const abortController = new AbortController();
214+
const signal = abortController.signal;
215+
216+
const customReason = new Error('Custom abort reason');
217+
218+
const deferred1 = defer<string>();
219+
const deferred2 = defer<number>();
220+
221+
let innerSignal: AbortSignal;
222+
223+
const promise = all(signal, signal => {
224+
innerSignal = signal;
225+
return [deferred1.promise, deferred2.promise];
226+
});
227+
228+
abortController.abort(customReason);
229+
230+
expect(innerSignal!.aborted).toBe(true);
231+
232+
// When external signal is aborted with custom reason,
233+
// promises should reject with AbortError
234+
deferred1.reject(new AbortError());
235+
deferred2.reject(new AbortError());
236+
237+
// The result should be AbortError (first rejection), not custom reason
238+
await expect(promise).rejects.toMatchObject({
239+
name: 'AbortError',
240+
});
241+
});
242+
243+
it('abort before all with custom reason', async () => {
244+
const abortController = new AbortController();
245+
const signal = abortController.signal;
246+
247+
const customReason = new Error('Custom abort reason');
248+
abortController.abort(customReason);
249+
250+
const executor = createSpy((signal: AbortSignal) => [Promise.resolve('test')]);
251+
252+
await expect(all(signal, executor)).rejects.toBe(customReason);
253+
254+
expect(executor.callCount).toBe(0);
255+
});
256+
257+
it('innerSignal receives custom reason on external abort', async () => {
258+
const abortController = new AbortController();
259+
const signal = abortController.signal;
260+
261+
const customReason = new Error('Custom abort reason');
262+
263+
const deferred1 = defer<string>();
264+
265+
let innerSignal: AbortSignal;
266+
267+
all(signal, signal => {
268+
innerSignal = signal;
269+
return [deferred1.promise];
270+
});
271+
272+
abortController.abort(customReason);
273+
274+
expect(innerSignal!.aborted).toBe(true);
275+
expect(innerSignal!.reason).toBe(customReason);
276+
});
277+
278+
it('innerSignal receives descriptive reason on promise rejection', async () => {
279+
const abortController = new AbortController();
280+
const signal = abortController.signal;
281+
282+
const deferred1 = defer<string>();
283+
const deferred2 = defer<number>();
284+
285+
let innerSignal: AbortSignal;
286+
287+
all(signal, signal => {
288+
innerSignal = signal;
289+
return [deferred1.promise, deferred2.promise];
290+
});
291+
292+
deferred1.reject(new Error('test'));
293+
294+
await nextTick();
295+
296+
expect(innerSignal!.aborted).toBe(true);
297+
expect(innerSignal!.reason).toMatchObject({
298+
name: 'AbortError',
299+
message: 'One of the promises passed to all() rejected',
300+
});
301+
});

src/all.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export function all<T>(
143143
): Promise<T[]> {
144144
return new Promise((resolve, reject) => {
145145
if (signal.aborted) {
146-
reject(new AbortError());
146+
reject(signal.reason ?? new AbortError());
147147
return;
148148
}
149149

@@ -157,7 +157,7 @@ export function all<T>(
157157
}
158158

159159
const abortListener = () => {
160-
innerAbortController.abort();
160+
innerAbortController.abort(signal.reason ?? new AbortError());
161161
};
162162

163163
signal.addEventListener('abort', abortListener);
@@ -189,7 +189,12 @@ export function all<T>(
189189
settled();
190190
},
191191
reason => {
192-
innerAbortController.abort();
192+
innerAbortController.abort(
193+
new AbortError(
194+
'One of the promises passed to all() rejected',
195+
false,
196+
),
197+
);
193198

194199
if (
195200
rejection == null ||

src/execute.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ it('resolve immediately', async () => {
3434

3535
it('resolve before abort', async () => {
3636
const abortController = new AbortController();
37-
const signal = abortController.signal;
37+
const signal = abortController.signal;
3838
const addEventListenerSpy = spyOn(signal, 'addEventListener');
3939
const removeEventListenerSpy = spyOn(signal, 'removeEventListener');
4040

@@ -319,3 +319,99 @@ it('async abort callback rejection', async () => {
319319
expect(addEventListenerSpy.callCount).toBe(1);
320320
expect(removeEventListenerSpy.callCount).toBe(1);
321321
});
322+
323+
it('abort with custom reason', async () => {
324+
const abortController = new AbortController();
325+
const signal = abortController.signal;
326+
327+
const customReason = new Error('Custom abort reason');
328+
const callback = createSpy((reason?: unknown) => {
329+
expect(reason).toBe(customReason);
330+
});
331+
332+
let result: PromiseSettledResult<string> | undefined;
333+
334+
execute<string>(signal, (resolve, reject) => {
335+
return callback;
336+
}).then(
337+
value => {
338+
result = {status: 'fulfilled', value};
339+
},
340+
reason => {
341+
result = {status: 'rejected', reason};
342+
},
343+
);
344+
345+
abortController.abort(customReason);
346+
347+
await nextTick();
348+
349+
expect(callback.callCount).toBe(1);
350+
expect(result).toMatchObject({
351+
status: 'rejected',
352+
reason: customReason,
353+
});
354+
});
355+
356+
it('abort before execute with custom reason', async () => {
357+
const abortController = new AbortController();
358+
const signal = abortController.signal;
359+
360+
const customReason = new Error('Custom abort reason');
361+
abortController.abort(customReason);
362+
363+
const executor = createSpy(
364+
(
365+
resolve: (value: string) => void,
366+
reject: (reason?: any) => void,
367+
): (() => void | PromiseLike<void>) => {
368+
return () => {};
369+
},
370+
);
371+
372+
await expect(execute(signal, executor)).rejects.toBe(customReason);
373+
374+
expect(executor.callCount).toBe(0);
375+
});
376+
377+
it('async abort callback with custom reason', async () => {
378+
const abortController = new AbortController();
379+
const signal = abortController.signal;
380+
381+
const customReason = new Error('Custom abort reason');
382+
const callbackDeferred = defer<void>();
383+
384+
const callback = createSpy((reason?: unknown) => {
385+
expect(reason).toBe(customReason);
386+
return callbackDeferred.promise;
387+
});
388+
389+
let result: PromiseSettledResult<string> | undefined;
390+
391+
execute<string>(signal, (resolve, reject) => {
392+
return callback;
393+
}).then(
394+
value => {
395+
result = {status: 'fulfilled', value};
396+
},
397+
reason => {
398+
result = {status: 'rejected', reason};
399+
},
400+
);
401+
402+
abortController.abort(customReason);
403+
404+
await nextTick();
405+
406+
expect(result).toBeUndefined();
407+
408+
callbackDeferred.resolve();
409+
410+
await nextTick();
411+
412+
expect(result).toMatchObject({
413+
status: 'rejected',
414+
reason: customReason,
415+
});
416+
expect(callback.callCount).toBe(1);
417+
});

src/execute.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ export function execute<T>(
1515
executor: (
1616
resolve: (value: T) => void,
1717
reject: (reason?: any) => void,
18-
) => () => void | PromiseLike<void>,
18+
) => (reason?: unknown) => void | PromiseLike<void>,
1919
): Promise<T> {
2020
return new Promise<T>((resolve, reject) => {
2121
if (signal.aborted) {
22-
reject(new AbortError());
22+
reject(signal.reason ?? new AbortError());
2323
return;
2424
}
2525

@@ -47,15 +47,15 @@ export function execute<T>(
4747
);
4848

4949
if (!finished) {
50-
const listener = () => {
51-
const callbackResult = callback();
50+
const abortListener = () => {
51+
const callbackResult = callback(signal.reason);
5252

5353
if (callbackResult == null) {
54-
reject(new AbortError());
54+
reject(signal.reason ?? new AbortError());
5555
} else {
5656
callbackResult.then(
5757
() => {
58-
reject(new AbortError());
58+
reject(signal.reason ?? new AbortError());
5959
},
6060
reason => {
6161
reject(reason);
@@ -66,10 +66,10 @@ export function execute<T>(
6666
finish();
6767
};
6868

69-
signal.addEventListener('abort', listener);
69+
signal.addEventListener('abort', abortListener);
7070

7171
removeAbortListener = () => {
72-
signal.removeEventListener('abort', listener);
72+
signal.removeEventListener('abort', abortListener);
7373
};
7474
}
7575
});

0 commit comments

Comments
 (0)