Skip to content

Commit c73abf7

Browse files
committed
feat: Add AbortSignal.any(signals), signal.throwIfAborted() and AbortSignal.timeout(time)
1 parent a14c75f commit c73abf7

5 files changed

Lines changed: 225 additions & 11 deletions

File tree

packages/react-native/src/private/webapis/dom/abort-api/AbortController.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export class AbortController {
2727
/**
2828
* Abort and signal to any observers that the associated activity is to be aborted.
2929
*/
30-
abort(): void {
31-
abortSignal(getSignal(this));
30+
abort(reason: unknown): void {
31+
abortSignal(reason, getSignal(this));
3232
}
3333
}
3434

packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,83 @@
22
* @flow strict
33
* @format
44
*/
5+
import DOMException from '../../errors/DOMException'
56
import Event from '../events/Event';
67
import EventTarget from '../events/EventTarget'
8+
import {AbortController} from './AbortController';
79

10+
const reasons = new WeakMap<AbortSignal, unknown>();
811

912

1013
/**
1114
* The signal class.
1215
* @see https://dom.spec.whatwg.org/#abortsignal
1316
*/
1417
export class AbortSignal extends EventTarget {
18+
/**
19+
* AbortSignal.timeout static method
20+
* Docs: https:developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
21+
* Spec: https://dom.spec.whatwg.org/#dom-abortsignal-timeout
22+
*/
23+
static timeout(timeInMs: number): AbortSignal {
24+
if (!(timeInMs >= 0)) {
25+
throw new TypeError(
26+
"Failed to execute 'timeout' on 'AbortSignal': The provided value have to be a non-negative number.",
27+
);
28+
}
29+
const controller = new AbortController();
30+
setTimeout(
31+
() =>
32+
controller.abort(new DOMException('signal timed out', 'TimeoutError')),
33+
timeInMs,
34+
);
35+
return controller.signal;
36+
}
37+
38+
/**
39+
* 3. AbortSignal.any static method
40+
* Docs: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
41+
* Spec: https://dom.spec.whatwg.org/#dom-abortsignal-any
42+
*/
43+
static any(signals: AbortSignal[]): AbortSignal {
44+
if (!Array.isArray(signals)) {
45+
throw new Error('The signals value must be an instance of Array');
46+
}
47+
48+
const controller = new AbortController();
49+
const listeners = [];
50+
const cleanup = () => listeners.forEach(unsubscribe => unsubscribe());
51+
52+
for (let i = 0; i < signals.length; i++) {
53+
const signal = signals[i];
54+
55+
// Validate that each item is an AbortSignal
56+
if (!(signal instanceof AbortSignal)) {
57+
cleanup(); // Remove all listeners added so far
58+
throw new Error(
59+
'The "signals[' +
60+
i +
61+
']" argument must be an instance of AbortSignal',
62+
);
63+
}
64+
65+
// Abort immediately if one of the signals is already aborted
66+
if (signal.aborted) {
67+
cleanup(); // Remove all listeners added so far
68+
controller.abort(signal.reason);
69+
break;
70+
}
71+
72+
const onAbort = () => {
73+
controller.abort(signal.reason);
74+
cleanup();
75+
};
76+
signal.addEventListener('abort', onAbort);
77+
listeners.push(() => signal.removeEventListener('abort', onAbort));
78+
}
79+
return controller.signal;
80+
}
81+
1582
/**
1683
* AbortSignal cannot be constructed directly.
1784
*/
@@ -36,6 +103,17 @@ export class AbortSignal extends EventTarget {
36103
}
37104
return aborted;
38105
}
106+
107+
// $FlowExpectedError[unsafe-getters-setters]
108+
get reason(): unknown {
109+
return reasons.get(this);
110+
}
111+
112+
throwIfAborted(): void {
113+
if (this.aborted) {
114+
throw this.reason;
115+
}
116+
}
39117
}
40118

41119
const listeners = new WeakMap<AbortSignal, (()=> void)>();
@@ -83,12 +161,16 @@ export function createAbortSignal(): AbortSignal {
83161
/**
84162
* Abort a given signal.
85163
*/
86-
export function abortSignal(signal: AbortSignal): void {
164+
export function abortSignal(
165+
reason: unknown | void = new DOMException('signal is aborted without reason', 'AbortError'),
166+
signal: AbortSignal,
167+
): void {
87168
if (abortedFlags.get(signal) !== false) {
88169
return;
89170
}
90171

91172
abortedFlags.set(signal, true);
173+
reasons.set(signal, reason);
92174
// $FlowExpectedError[incompatible-type]
93175
signal.dispatchEvent(new Event('abort'));
94176
}
@@ -102,6 +184,7 @@ const abortedFlags = new WeakMap<AbortSignal, boolean>()
102184
//$FlowExpectedError[cannot-write]
103185
Object.defineProperties(AbortSignal.prototype, {
104186
aborted: {enumerable: true},
187+
reason: {enumerable: true},
105188
});
106189

107190

packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
* @format
99
*/
1010

11-
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12-
11+
import DOMException from '../../../errors/DOMException';
1312
import {AbortController} from '../AbortController';
1413
import {AbortSignal} from '../AbortSignal';
1514
import Event from 'react-native/src/private/webapis/dom/events/Event';
@@ -58,6 +57,7 @@ describe('AbortController', () => {
5857

5958
it('should not be callable', () => {
6059
expect(() => {
60+
// $FlowExpectedError[prop-missing]
6161
// $FlowExpectedError[constructor-as-function]
6262
AbortController();
6363
}).toThrow(TypeError);
@@ -75,6 +75,7 @@ describe('AbortController', () => {
7575
});
7676

7777
it('should be stringified as [object AbortController]', () => {
78+
// $FlowExpectedError[method-unbinding]
7879
expect(Object.prototype.toString.call(controller)).toBe(
7980
'[object AbortController]',
8081
);
@@ -103,11 +104,9 @@ describe('AbortController', () => {
103104
const keys = new Set([
104105
'aborted',
105106
'onabort',
106-
// TODO
107-
// 'reason',
107+
'reason',
108+
// TODO: The modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops.
108109
// 'throwIfAborted',
109-
// 'when',
110-
// TODO: Problem with EventTarget: the modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops.
111110
// 'addEventListener',
112111
// 'dispatchEvent',
113112
// 'removeEventListener',
@@ -125,7 +124,12 @@ describe('AbortController', () => {
125124
expect(signal.aborted).toBe(false);
126125
});
127126

127+
it("should have 'reason' property which is undefined by default", () => {
128+
expect(signal.reason).toBe(undefined);
129+
});
130+
128131
it("should have 'onabort' property which is null by default", () => {
132+
// $FlowExpectedError[prop-missing]
129133
expect(signal.onabort).toBe(null);
130134
});
131135

@@ -144,6 +148,7 @@ describe('AbortController', () => {
144148
});
145149

146150
it('should be stringified as [object AbortSignal]', () => {
151+
// $FlowExpectedError[method-unbinding]
147152
expect(Object.prototype.toString.call(signal)).toBe(
148153
'[object AbortSignal]',
149154
);
@@ -156,6 +161,45 @@ describe('AbortController', () => {
156161
expect(controller.signal.aborted).toBe(true);
157162
});
158163

164+
it("should set default 'reason' when called without an argument", () => {
165+
controller.abort();
166+
167+
expect(controller.signal.reason).toBeInstanceOf(DOMException);
168+
expect(controller.signal.reason).toMatchObject({
169+
name: 'AbortError',
170+
message: 'signal is aborted without reason',
171+
});
172+
});
173+
174+
it("should set the provided 'reason' when called with an argument", () => {
175+
const reason = new Error('boom');
176+
177+
controller.abort(reason);
178+
179+
expect(controller.signal.reason).toBe(reason);
180+
});
181+
182+
it("should make 'throwIfAborted' throw the abort reason", () => {
183+
const reason = {message: 'boom'};
184+
185+
controller.abort(reason);
186+
187+
let thrown;
188+
try {
189+
controller.signal.throwIfAborted();
190+
} catch (error) {
191+
thrown = error;
192+
}
193+
194+
expect(thrown).toBe(reason);
195+
});
196+
197+
it("should not throw from 'throwIfAborted' before aborting", () => {
198+
expect(() => {
199+
controller.signal.throwIfAborted();
200+
}).not.toThrow();
201+
});
202+
159203
it("should fire 'abort' event on 'signal' (addEventListener)", () => {
160204
const listener = createListener();
161205
controller.signal.addEventListener('abort', listener);
@@ -167,6 +211,7 @@ describe('AbortController', () => {
167211
it("should fire 'abort' event on 'signal' (onabort)", () => {
168212
const listener = createListener();
169213
// $FlowExpectedError[incompatible-type]
214+
// $FlowExpectedError[prop-missing]
170215
controller.signal.onabort = listener;
171216
controller.abort();
172217

@@ -186,15 +231,79 @@ describe('AbortController', () => {
186231

187232
it("should throw a TypeError if 'this' is not an AbortController object", () => {
188233
expect(() => {
234+
// $FlowExpectedError[method-unbinding]
189235
controller.abort.call({});
190236
}).toThrow(TypeError);
191237
});
192238
});
239+
240+
describe("'any' static method", () => {
241+
it("should abort when one of the provided signals aborts", () => {
242+
const first = new AbortController();
243+
const second = new AbortController();
244+
const reason = new Error('stop');
245+
246+
const signal = AbortSignal.any([first.signal, second.signal]);
247+
248+
expect(signal).toBeInstanceOf(AbortSignal);
249+
expect(signal.aborted).toBe(false);
250+
251+
second.abort(reason);
252+
253+
expect(signal.aborted).toBe(true);
254+
expect(signal.reason).toBe(reason);
255+
});
256+
257+
it("should abort immediately if one of the provided signals is already aborted", () => {
258+
const first = new AbortController();
259+
const second = new AbortController();
260+
const reason = new Error('already aborted');
261+
262+
second.abort(reason);
263+
264+
const signal = AbortSignal.any([first.signal, second.signal]);
265+
266+
expect(signal.aborted).toBe(true);
267+
expect(signal.reason).toBe(reason);
268+
});
269+
});
270+
271+
describe("'timeout' static method", () => {
272+
beforeEach(() => {
273+
jest.useFakeTimers();
274+
});
275+
276+
afterEach(() => {
277+
jest.useRealTimers();
278+
});
279+
280+
it("should abort after the timeout with a TimeoutError reason", () => {
281+
const signal = AbortSignal.timeout(10);
282+
283+
expect(signal.aborted).toBe(false);
284+
285+
jest.advanceTimersByTime(10);
286+
287+
expect(signal.aborted).toBe(true);
288+
expect(signal.reason).toBeInstanceOf(DOMException);
289+
expect(signal.reason).toMatchObject({
290+
name: 'TimeoutError',
291+
message: 'signal timed out',
292+
});
293+
});
294+
295+
it("should throw a TypeError for a negative timeout", () => {
296+
expect(() => {
297+
AbortSignal.timeout(-1);
298+
}).toThrow(TypeError);
299+
});
300+
});
193301
});
194302

195303
describe('AbortSignal', () => {
196304
it('should not be callable', () => {
197305
expect(() => {
306+
// $FlowExpectedError[prop-missing]
198307
// $FlowExpectedError[constructor-as-function]
199308
AbortSignal();
200309
}).toThrow(TypeError);

packages/react-native/src/types/globals.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,26 @@ declare global {
626626
capture?: boolean | undefined;
627627
},
628628
) => void;
629+
630+
/**
631+
* Throws the abort reason if the signal has been aborted.
632+
* Otherwise, does nothing.
633+
*
634+
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted)
635+
*/
636+
throwIfAborted(): void;
637+
/**
638+
* The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal.
639+
*
640+
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static)
641+
*/
642+
static any(signals: AbortSignal[]): AbortSignal;
643+
/**
644+
* The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time.
645+
*
646+
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static)
647+
*/
648+
static timeout(milliseconds: number): AbortSignal;
629649
}
630650

631651
class AbortController {
@@ -640,7 +660,7 @@ declare global {
640660
/**
641661
* Abort and signal to any observers that the associated activity is to be aborted.
642662
*/
643-
abort(): void;
663+
abort(reason?: any): void;
644664
}
645665

646666
interface FileReaderEventMap {

packages/react-native/types/__typetests__/globals.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,13 @@ const fetchCopy: WindowOrWorkerGlobalScope['fetch'] = fetch;
142142
const myHeaders = new Headers();
143143
myHeaders.append('Content-Type', 'image/jpeg');
144144

145+
const controller = new AbortController();
146+
145147
const myInit: RequestInit = {
146148
method: 'GET',
147149
headers: myHeaders,
148150
mode: 'cors',
149-
signal: new AbortSignal(),
151+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
150152
};
151153

152154
const myRequest = new Request('flowers.jpg');

0 commit comments

Comments
 (0)