diff --git a/packages/react-native/Libraries/Core/setUpXHR.js b/packages/react-native/Libraries/Core/setUpXHR.js index 5b7e03cbfbce..478a381fde80 100644 --- a/packages/react-native/Libraries/Core/setUpXHR.js +++ b/packages/react-native/Libraries/Core/setUpXHR.js @@ -36,9 +36,12 @@ 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..269baa0844cd --- /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(reason: unknown): void { + abortSignal(reason, 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..9cb95d163451 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/AbortSignal.js @@ -0,0 +1,197 @@ +/** + * @flow strict + * @format + */ +import DOMException from '../../errors/DOMException'; +import Event from '../events/Event'; +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. + */ + 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; + } + + // $FlowExpectedError[unsafe-getters-setters] + get reason(): unknown { + return reasons.get(this); + } + + throwIfAborted(): void { + if (this.aborted) { + throw this.reason; + } + } +} + +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( + 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')); +} + +/** + * Aborted flag for each instances. + */ +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') { + 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..94abc36f01d5 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/abort-api-test.js @@ -0,0 +1,319 @@ +/** + * 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 DOMException from '../../../errors/DOMException'; +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[prop-missing] + // $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]', () => { + // $FlowExpectedError[method-unbinding] + 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', + 'reason', + // TODO: The modern class syntax was specifically designed to prevent this, ensuring methods don't "pollute" standard loops. + // 'throwIfAborted', + // '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 '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); + }); + + 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]', () => { + // $FlowExpectedError[method-unbinding] + 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 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); + controller.abort(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should fire 'abort' event on 'signal' (onabort)", () => { + const listener = createListener(); + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[prop-missing] + 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(() => { + // $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); + }); + + 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/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'); 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"