From a14c75f893bedbfd7a3ac3eb3365bc2ddd7b69fe Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:03:41 +0200 Subject: [PATCH 1/2] Fork `abort-controller` to `react-native` repo --- .../react-native/Libraries/Core/setUpXHR.js | 5 +- .../webapis/dom/abort-api/AbortController.js | 69 ++++++ .../webapis/dom/abort-api/AbortSignal.js | 114 ++++++++++ .../dom/abort-api/__tests__/abort-api-test.js | 210 ++++++++++++++++++ yarn.lock | 12 - 5 files changed, 396 insertions(+), 14 deletions(-) create mode 100644 packages/react-native/src/private/webapis/dom/abort-api/AbortController.js create mode 100644 packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js create mode 100644 packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js diff --git a/packages/react-native/Libraries/Core/setUpXHR.js b/packages/react-native/Libraries/Core/setUpXHR.js index 5b7e03cbfbce..d10a8f7b5fe2 100644 --- a/packages/react-native/Libraries/Core/setUpXHR.js +++ b/packages/react-native/Libraries/Core/setUpXHR.js @@ -36,9 +36,10 @@ polyfillGlobal('URL', () => require('../Blob/URL').URL); polyfillGlobal('URLSearchParams', () => require('../Blob/URL').URLSearchParams); polyfillGlobal( 'AbortController', - () => require('abort-controller/dist/abort-controller').AbortController, // flowlint-line untyped-import:off + () => require('../../src/private/webapis/dom/abort-api/AbortController').AbortController, // flowlint-line untyped-import:off ); polyfillGlobal( 'AbortSignal', - () => require('abort-controller/dist/abort-controller').AbortSignal, // flowlint-line untyped-import:off + () => + require('../../src/private/webapis/dom/abort-api/AbortSignal').AbortSignal, // flowlint-line untyped-import:off ); diff --git a/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js b/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js new file mode 100644 index 000000000000..8dcaab390ea8 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js @@ -0,0 +1,69 @@ +/** + * @flow strict + * @format + */ +import {AbortSignal, abortSignal, createAbortSignal} from './AbortSignal'; + +/** + * The AbortController. + * @see https://dom.spec.whatwg.org/#abortcontroller + */ +export class AbortController { + /** + * Initialize this controller. + */ + constructor() { + signals.set(this, createAbortSignal()); + } + + /** + * Returns the `AbortSignal` object associated with this object. + */ + // $FlowExpectedError[unsafe-getters-setters] + get signal(): AbortSignal { + return getSignal(this); + } + + /** + * Abort and signal to any observers that the associated activity is to be aborted. + */ + abort(): void { + abortSignal(getSignal(this)); + } +} + +/** + * Associated signals. + */ +const signals = new WeakMap() + +/** + * Get the associated signal of a given controller. + */ +function getSignal(controller: AbortController): AbortSignal { + const signal = signals.get(controller) + if (signal == null) { + throw new TypeError( + `Expected 'this' to be an 'AbortController' object, but got ${ + // $FlowExpectedError[invalid-compare] + controller === null ? 'null' : typeof controller + }`, + ); + } + return signal +} + +// Properties should be enumerable. +//$FlowExpectedError[cannot-write] +Object.defineProperties(AbortController.prototype, { + signal: { enumerable: true }, + abort: { enumerable: true }, +}) + +if (typeof Symbol === "function" && typeof Symbol.toStringTag === "symbol") { + //$FlowExpectedError[cannot-write] + Object.defineProperty(AbortController.prototype, Symbol.toStringTag, { + configurable: true, + value: 'AbortController', + }); +} diff --git a/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js b/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js new file mode 100644 index 000000000000..7ad0824f1dd2 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js @@ -0,0 +1,114 @@ +/** + * @flow strict + * @format + */ +import Event from '../events/Event'; +import EventTarget from '../events/EventTarget' + + + +/** + * The signal class. + * @see https://dom.spec.whatwg.org/#abortsignal + */ +export class AbortSignal extends EventTarget { + /** + * AbortSignal cannot be constructed directly. + */ + constructor() { + super(); + throw new TypeError('AbortSignal cannot be constructed directly'); + } + + /** + * Returns `true` if this `AbortSignal`'s `AbortController` has signaled to abort, and `false` otherwise. + */ + // $FlowExpectedError[unsafe-getters-setters] + get aborted(): boolean { + const aborted = abortedFlags.get(this); + if (typeof aborted !== 'boolean') { + throw new TypeError( + `Expected 'this' to be an 'AbortSignal' object, but got ${ + // $FlowExpectedError[invalid-compare] + this === null ? 'null' : typeof this + }`, + ); + } + return aborted; + } +} + +const listeners = new WeakMap void)>(); +Object.defineProperty(AbortSignal.prototype, `onabort`, { + enumerable: true, + configurable: true, + get() { + // $FlowExpectedError[object-this-reference] + return listeners.get(this) || null; + }, + // $FlowExpectedError[missing-local-annot] + set(value) { + // $FlowExpectedError[object-this-reference] + const currentListener = listeners.get(this); + if (currentListener === value) return; // same handler? do nothing! + if (currentListener) { + // Before setting a new listener, remove the old one if exists + // $FlowExpectedError[object-this-reference] + this.removeEventListener('abort', currentListener); + } + if (typeof value === 'function') { + // $FlowExpectedError[object-this-reference] + listeners.set(this, value); + // $FlowExpectedError[object-this-reference] + this.addEventListener('abort', value); + } else { + // $FlowExpectedError[object-this-reference] + listeners.delete(this); + } + }, +}); + + +/** + * Create an AbortSignal object. + */ +export function createAbortSignal(): AbortSignal { + const signal = Object.create(AbortSignal.prototype); + // $FlowExpectedError[incompatible-type] + EventTarget.call(signal); + abortedFlags.set(signal, false); + return signal; +} + +/** + * Abort a given signal. + */ +export function abortSignal(signal: AbortSignal): void { + if (abortedFlags.get(signal) !== false) { + return; + } + + abortedFlags.set(signal, true); + // $FlowExpectedError[incompatible-type] + signal.dispatchEvent(new Event('abort')); +} + +/** + * Aborted flag for each instances. + */ +const abortedFlags = new WeakMap() + +// Properties should be enumerable. +//$FlowExpectedError[cannot-write] +Object.defineProperties(AbortSignal.prototype, { + aborted: {enumerable: true}, +}); + + +// `toString()` should return `"[object AbortSignal]"` +if (typeof Symbol === "function" && typeof Symbol.toStringTag === "symbol") { + Object.defineProperty(AbortSignal.prototype, Symbol.toStringTag, { + configurable: true, + value: "AbortSignal", + }) +} diff --git a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js new file mode 100644 index 000000000000..f033743ad8eb --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js @@ -0,0 +1,210 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import {AbortController} from '../AbortController'; +import {AbortSignal} from '../AbortSignal'; +import Event from 'react-native/src/private/webapis/dom/events/Event'; +import EventTarget from 'react-native/src/private/webapis/dom/events/EventTarget'; + +let listenerCallOrder = 0; + +type EventRecordingListener = JestMockFn<[Event], void> & { + eventData?: { + callOrder: number, + composedPath: ReadonlyArray, + currentTarget: Event['currentTarget'], + eventPhase: Event['eventPhase'], + target: Event['target'], + }, + ... +}; + +function createListener( + implementation?: Event => void, +): EventRecordingListener { + // $FlowExpectedError[incompatible-type] + const listener: EventRecordingListener = jest.fn((event: Event) => { + listener.eventData = { + callOrder: listenerCallOrder++, + composedPath: event.composedPath(), + currentTarget: event.currentTarget, + eventPhase: event.eventPhase, + target: event.target, + }; + + if (implementation) { + implementation(event); + } + }); + + return listener; +} + +describe('AbortController', () => { + let controller: AbortController; + + beforeEach(() => { + controller = new AbortController(); + }); + + it('should not be callable', () => { + expect(() => { + // $FlowExpectedError[constructor-as-function] + AbortController(); + }).toThrow(TypeError); + }); + + it('should have 2 properties', () => { + const keys = new Set(['signal', 'abort']); + + for (const key in controller) { + expect(keys.has(key)).toBe(true); + keys.delete(key); + } + + expect(keys.size).toBe(0); + }); + + it('should be stringified as [object AbortController]', () => { + expect(Object.prototype.toString.call(controller)).toBe( + '[object AbortController]', + ); + }); + + describe("'signal' property", () => { + let signal: AbortSignal; + + beforeEach(() => { + signal = controller.signal; + }); + + it('should return the same instance always', () => { + expect(controller.signal).toBe(signal); + }); + + it('should be an AbortSignal object', () => { + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it('should be an EventTarget object', () => { + expect(signal).toBeInstanceOf(EventTarget); + }); + + it('should have required properties', () => { + const keys = new Set([ + 'aborted', + 'onabort', + // TODO + // 'reason', + // 'throwIfAborted', + // 'when', + // TODO: Problem with EventTarget: the modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops. + // 'addEventListener', + // 'dispatchEvent', + // 'removeEventListener', + ]); + + for (const key in signal) { + expect(keys.has(key)).toBe(true); + keys.delete(key); + } + + expect(keys.size).toBe(0); + }); + + it("should have 'aborted' property which is false by default", () => { + expect(signal.aborted).toBe(false); + }); + + it("should have 'onabort' property which is null by default", () => { + expect(signal.onabort).toBe(null); + }); + + it("should throw a TypeError if 'signal.aborted' getter is called with non AbortSignal object", () => { + const proto = Object.getPrototypeOf(signal); + const descriptor = Object.getOwnPropertyDescriptor(proto, 'aborted'); + const getAborted = descriptor?.get; + + expect(() => { + if (getAborted) { + getAborted.call({}); + } else { + throw new TypeError(); + } + }).toThrow(TypeError); + }); + + it('should be stringified as [object AbortSignal]', () => { + expect(Object.prototype.toString.call(signal)).toBe( + '[object AbortSignal]', + ); + }); + }); + + describe("'abort' method", () => { + it("should set true to 'signal.aborted' property", () => { + controller.abort(); + expect(controller.signal.aborted).toBe(true); + }); + + it("should fire 'abort' event on 'signal' (addEventListener)", () => { + const listener = createListener(); + controller.signal.addEventListener('abort', listener); + controller.abort(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should fire 'abort' event on 'signal' (onabort)", () => { + const listener = createListener(); + // $FlowExpectedError[incompatible-type] + controller.signal.onabort = listener; + controller.abort(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should not fire 'abort' event twice", () => { + const listener = createListener(); + controller.signal.addEventListener('abort', listener); + + controller.abort(); + controller.abort(); + controller.abort(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should throw a TypeError if 'this' is not an AbortController object", () => { + expect(() => { + controller.abort.call({}); + }).toThrow(TypeError); + }); + }); +}); + +describe('AbortSignal', () => { + it('should not be callable', () => { + expect(() => { + // $FlowExpectedError[constructor-as-function] + AbortSignal(); + }).toThrow(TypeError); + }); + + it("should throw a TypeError when it's constructed directly", () => { + expect(() => { + // $FlowExpectedError[cannot-new] + // eslint-disable-next-line no-new + new AbortSignal(); + }).toThrow('AbortSignal cannot be constructed directly'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1a0833da46ce..e6c6d7a1abbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,13 +2589,6 @@ resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz#00d1dd940b218dff2e49309d410d8bb212159225" integrity sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw== -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - accepts@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" @@ -4546,11 +4539,6 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - eventemitter3@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" From bc9cea8e5a30fc43b99b678410e416fb98a2900a Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:55:07 +0200 Subject: [PATCH 2/2] feat: Add `AbortSignal.any(signals)`, `signal.throwIfAborted()` and `AbortSignal.timeout(time)` --- .../react-native/Libraries/Core/setUpXHR.js | 4 +- .../webapis/dom/abort-api/AbortController.js | 18 +-- .../webapis/dom/abort-api/AbortSignal.js | 103 +++++++++++++-- .../dom/abort-api/__tests__/abort-api-test.js | 121 +++++++++++++++++- packages/react-native/src/types/globals.d.ts | 22 +++- .../types/__typetests__/globals.tsx | 4 +- 6 files changed, 244 insertions(+), 28 deletions(-) diff --git a/packages/react-native/Libraries/Core/setUpXHR.js b/packages/react-native/Libraries/Core/setUpXHR.js index d10a8f7b5fe2..478a381fde80 100644 --- a/packages/react-native/Libraries/Core/setUpXHR.js +++ b/packages/react-native/Libraries/Core/setUpXHR.js @@ -36,7 +36,9 @@ polyfillGlobal('URL', () => require('../Blob/URL').URL); polyfillGlobal('URLSearchParams', () => require('../Blob/URL').URLSearchParams); polyfillGlobal( 'AbortController', - () => require('../../src/private/webapis/dom/abort-api/AbortController').AbortController, // flowlint-line untyped-import:off + () => + require('../../src/private/webapis/dom/abort-api/AbortController') + .AbortController, // flowlint-line untyped-import:off ); polyfillGlobal( 'AbortSignal', diff --git a/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js b/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js index 8dcaab390ea8..269baa0844cd 100644 --- a/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js +++ b/packages/react-native/src/private/webapis/dom/abort-api/AbortController.js @@ -27,21 +27,21 @@ export class AbortController { /** * Abort and signal to any observers that the associated activity is to be aborted. */ - abort(): void { - abortSignal(getSignal(this)); + abort(reason: unknown): void { + abortSignal(reason, getSignal(this)); } } /** * Associated signals. */ -const signals = new WeakMap() +const signals = new WeakMap(); /** * Get the associated signal of a given controller. */ function getSignal(controller: AbortController): AbortSignal { - const signal = signals.get(controller) + const signal = signals.get(controller); if (signal == null) { throw new TypeError( `Expected 'this' to be an 'AbortController' object, but got ${ @@ -50,17 +50,17 @@ function getSignal(controller: AbortController): AbortSignal { }`, ); } - return signal + return signal; } // Properties should be enumerable. //$FlowExpectedError[cannot-write] Object.defineProperties(AbortController.prototype, { - signal: { enumerable: true }, - abort: { enumerable: true }, -}) + signal: {enumerable: true}, + abort: {enumerable: true}, +}); -if (typeof Symbol === "function" && typeof Symbol.toStringTag === "symbol") { +if (typeof Symbol === 'function' && typeof Symbol.toStringTag === 'symbol') { //$FlowExpectedError[cannot-write] Object.defineProperty(AbortController.prototype, Symbol.toStringTag, { configurable: true, diff --git a/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js b/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js index 7ad0824f1dd2..9cb95d163451 100644 --- a/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js +++ b/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js @@ -2,16 +2,82 @@ * @flow strict * @format */ +import DOMException from '../../errors/DOMException'; import Event from '../events/Event'; -import EventTarget from '../events/EventTarget' - +import EventTarget from '../events/EventTarget'; +import {AbortController} from './AbortController'; +const reasons = new WeakMap(); /** * The signal class. * @see https://dom.spec.whatwg.org/#abortsignal */ export class AbortSignal extends EventTarget { + /** + * AbortSignal.timeout static method + * Docs: https:developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static + * Spec: https://dom.spec.whatwg.org/#dom-abortsignal-timeout + */ + static timeout(timeInMs: number): AbortSignal { + if (!(timeInMs >= 0)) { + throw new TypeError( + "Failed to execute 'timeout' on 'AbortSignal': The provided value have to be a non-negative number.", + ); + } + const controller = new AbortController(); + setTimeout( + () => + controller.abort(new DOMException('signal timed out', 'TimeoutError')), + timeInMs, + ); + return controller.signal; + } + + /** + * 3. AbortSignal.any static method + * Docs: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static + * Spec: https://dom.spec.whatwg.org/#dom-abortsignal-any + */ + static any(signals: AbortSignal[]): AbortSignal { + if (!Array.isArray(signals)) { + throw new Error('The signals value must be an instance of Array'); + } + + const controller = new AbortController(); + const listeners = []; + const cleanup = () => listeners.forEach(unsubscribe => unsubscribe()); + + for (let i = 0; i < signals.length; i++) { + const signal = signals[i]; + + // Validate that each item is an AbortSignal + if (!(signal instanceof AbortSignal)) { + cleanup(); // Remove all listeners added so far + throw new Error( + 'The "signals[' + + i + + ']" argument must be an instance of AbortSignal', + ); + } + + // Abort immediately if one of the signals is already aborted + if (signal.aborted) { + cleanup(); // Remove all listeners added so far + controller.abort(signal.reason); + break; + } + + const onAbort = () => { + controller.abort(signal.reason); + cleanup(); + }; + signal.addEventListener('abort', onAbort); + listeners.push(() => signal.removeEventListener('abort', onAbort)); + } + return controller.signal; + } + /** * AbortSignal cannot be constructed directly. */ @@ -36,9 +102,20 @@ export class AbortSignal extends EventTarget { } return aborted; } + + // $FlowExpectedError[unsafe-getters-setters] + get reason(): unknown { + return reasons.get(this); + } + + throwIfAborted(): void { + if (this.aborted) { + throw this.reason; + } + } } -const listeners = new WeakMap void)>(); +const listeners = new WeakMap void>(); Object.defineProperty(AbortSignal.prototype, `onabort`, { enumerable: true, configurable: true, @@ -68,7 +145,6 @@ Object.defineProperty(AbortSignal.prototype, `onabort`, { }, }); - /** * Create an AbortSignal object. */ @@ -83,12 +159,19 @@ export function createAbortSignal(): AbortSignal { /** * Abort a given signal. */ -export function abortSignal(signal: AbortSignal): void { +export function abortSignal( + reason: unknown | void = new DOMException( + 'signal is aborted without reason', + 'AbortError', + ), + signal: AbortSignal, +): void { if (abortedFlags.get(signal) !== false) { return; } abortedFlags.set(signal, true); + reasons.set(signal, reason); // $FlowExpectedError[incompatible-type] signal.dispatchEvent(new Event('abort')); } @@ -96,19 +179,19 @@ export function abortSignal(signal: AbortSignal): void { /** * Aborted flag for each instances. */ -const abortedFlags = new WeakMap() +const abortedFlags = new WeakMap(); // Properties should be enumerable. //$FlowExpectedError[cannot-write] Object.defineProperties(AbortSignal.prototype, { aborted: {enumerable: true}, + reason: {enumerable: true}, }); - // `toString()` should return `"[object AbortSignal]"` -if (typeof Symbol === "function" && typeof Symbol.toStringTag === "symbol") { +if (typeof Symbol === 'function' && typeof Symbol.toStringTag === 'symbol') { Object.defineProperty(AbortSignal.prototype, Symbol.toStringTag, { configurable: true, - value: "AbortSignal", - }) + value: 'AbortSignal', + }); } diff --git a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js index f033743ad8eb..94abc36f01d5 100644 --- a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js +++ b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js @@ -8,8 +8,7 @@ * @format */ -import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; - +import DOMException from '../../../errors/DOMException'; import {AbortController} from '../AbortController'; import {AbortSignal} from '../AbortSignal'; import Event from 'react-native/src/private/webapis/dom/events/Event'; @@ -58,6 +57,7 @@ describe('AbortController', () => { it('should not be callable', () => { expect(() => { + // $FlowExpectedError[prop-missing] // $FlowExpectedError[constructor-as-function] AbortController(); }).toThrow(TypeError); @@ -75,6 +75,7 @@ describe('AbortController', () => { }); it('should be stringified as [object AbortController]', () => { + // $FlowExpectedError[method-unbinding] expect(Object.prototype.toString.call(controller)).toBe( '[object AbortController]', ); @@ -103,11 +104,9 @@ describe('AbortController', () => { const keys = new Set([ 'aborted', 'onabort', - // TODO - // 'reason', + 'reason', + // TODO: The modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops. // 'throwIfAborted', - // 'when', - // TODO: Problem with EventTarget: the modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops. // 'addEventListener', // 'dispatchEvent', // 'removeEventListener', @@ -125,7 +124,12 @@ describe('AbortController', () => { expect(signal.aborted).toBe(false); }); + it("should have 'reason' property which is undefined by default", () => { + expect(signal.reason).toBe(undefined); + }); + it("should have 'onabort' property which is null by default", () => { + // $FlowExpectedError[prop-missing] expect(signal.onabort).toBe(null); }); @@ -144,6 +148,7 @@ describe('AbortController', () => { }); it('should be stringified as [object AbortSignal]', () => { + // $FlowExpectedError[method-unbinding] expect(Object.prototype.toString.call(signal)).toBe( '[object AbortSignal]', ); @@ -156,6 +161,45 @@ describe('AbortController', () => { expect(controller.signal.aborted).toBe(true); }); + it("should set default 'reason' when called without an argument", () => { + controller.abort(); + + expect(controller.signal.reason).toBeInstanceOf(DOMException); + expect(controller.signal.reason).toMatchObject({ + name: 'AbortError', + message: 'signal is aborted without reason', + }); + }); + + it("should set the provided 'reason' when called with an argument", () => { + const reason = new Error('boom'); + + controller.abort(reason); + + expect(controller.signal.reason).toBe(reason); + }); + + it("should make 'throwIfAborted' throw the abort reason", () => { + const reason = {message: 'boom'}; + + controller.abort(reason); + + let thrown; + try { + controller.signal.throwIfAborted(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBe(reason); + }); + + it("should not throw from 'throwIfAborted' before aborting", () => { + expect(() => { + controller.signal.throwIfAborted(); + }).not.toThrow(); + }); + it("should fire 'abort' event on 'signal' (addEventListener)", () => { const listener = createListener(); controller.signal.addEventListener('abort', listener); @@ -167,6 +211,7 @@ describe('AbortController', () => { it("should fire 'abort' event on 'signal' (onabort)", () => { const listener = createListener(); // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[prop-missing] controller.signal.onabort = listener; controller.abort(); @@ -186,15 +231,79 @@ describe('AbortController', () => { it("should throw a TypeError if 'this' is not an AbortController object", () => { expect(() => { + // $FlowExpectedError[method-unbinding] controller.abort.call({}); }).toThrow(TypeError); }); }); + + describe("'any' static method", () => { + it('should abort when one of the provided signals aborts', () => { + const first = new AbortController(); + const second = new AbortController(); + const reason = new Error('stop'); + + const signal = AbortSignal.any([first.signal, second.signal]); + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + + second.abort(reason); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe(reason); + }); + + it('should abort immediately if one of the provided signals is already aborted', () => { + const first = new AbortController(); + const second = new AbortController(); + const reason = new Error('already aborted'); + + second.abort(reason); + + const signal = AbortSignal.any([first.signal, second.signal]); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe(reason); + }); + }); + + describe("'timeout' static method", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should abort after the timeout with a TimeoutError reason', () => { + const signal = AbortSignal.timeout(10); + + expect(signal.aborted).toBe(false); + + jest.advanceTimersByTime(10); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBeInstanceOf(DOMException); + expect(signal.reason).toMatchObject({ + name: 'TimeoutError', + message: 'signal timed out', + }); + }); + + it('should throw a TypeError for a negative timeout', () => { + expect(() => { + AbortSignal.timeout(-1); + }).toThrow(TypeError); + }); + }); }); describe('AbortSignal', () => { it('should not be callable', () => { expect(() => { + // $FlowExpectedError[prop-missing] // $FlowExpectedError[constructor-as-function] AbortSignal(); }).toThrow(TypeError); diff --git a/packages/react-native/src/types/globals.d.ts b/packages/react-native/src/types/globals.d.ts index 4082da352804..8cf04d7ecc4c 100644 --- a/packages/react-native/src/types/globals.d.ts +++ b/packages/react-native/src/types/globals.d.ts @@ -626,6 +626,26 @@ declare global { capture?: boolean | undefined; }, ) => void; + + /** + * Throws the abort reason if the signal has been aborted. + * Otherwise, does nothing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) + */ + throwIfAborted(): void; + /** + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) + */ + static any(signals: AbortSignal[]): AbortSignal; + /** + * The **`AbortSignal.timeout()`** static method returns an AbortSignal that will automatically abort after a specified time. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/timeout_static) + */ + static timeout(milliseconds: number): AbortSignal; } class AbortController { @@ -640,7 +660,7 @@ declare global { /** * Abort and signal to any observers that the associated activity is to be aborted. */ - abort(): void; + abort(reason?: any): void; } interface FileReaderEventMap { diff --git a/packages/react-native/types/__typetests__/globals.tsx b/packages/react-native/types/__typetests__/globals.tsx index 8bacebf8c5f7..7d95b048a3d5 100644 --- a/packages/react-native/types/__typetests__/globals.tsx +++ b/packages/react-native/types/__typetests__/globals.tsx @@ -142,11 +142,13 @@ const fetchCopy: WindowOrWorkerGlobalScope['fetch'] = fetch; const myHeaders = new Headers(); myHeaders.append('Content-Type', 'image/jpeg'); +const controller = new AbortController(); + const myInit: RequestInit = { method: 'GET', headers: myHeaders, mode: 'cors', - signal: new AbortSignal(), + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]), }; const myRequest = new Request('flowers.jpg');