From a20bd807ca117dcd3e79f46b4d9dc3ccef431687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Wed, 1 Jul 2026 03:03:44 -0700 Subject: [PATCH] Make AbortSignal.any spec-compliant with a multi-pass implementation (#57381) Summary: `AbortSignal.any` previously processed the input signals in a single pass, attaching an abort listener to each signal as it iterated and unwinding them with a `cleanup()` call whenever it later encountered an already-aborted (or invalid) signal. This deviates from the specification, which validates the whole list, then scans it for an already-aborted signal (returning an aborted signal immediately), and only then subscribes to the remaining signals. Process the list in three passes instead: - The first pass validates that every entry is an `AbortSignal`, throwing before any other work so an invalid entry never short-circuits ahead of an earlier already-aborted one. - The second pass returns an already-aborted signal (via `AbortSignal.abort(reason)`) if any input is already aborted. No listeners are registered yet, so there is nothing to unwind on an early return. - The third pass subscribes to every signal, since all of them are valid and none is aborted at that point. This removes the need to clean up partially-registered listeners mid-setup and matches the behavior described in the DOM specification. Also remove a dead branch in `AbortController`'s `getSignal` helper. The `controller === null ? 'null' : typeof controller` expression was unreachable for `null` input because the preceding `controller[SIGNAL_KEY]` access already throws a `TypeError` on `null`/`undefined`, and it only existed by suppressing a Flow `invalid-compare` error. Use `typeof controller` directly so the suppression is no longer needed. Changelog: [General][Fixed] - Make `AbortSignal.any()` process its input signals in multiple passes to match the DOM specification Reviewed By: javache Differential Revision: D110185361 --- .../webapis/dom/abort-api/AbortController.js | 5 +--- .../webapis/dom/abort-api/AbortSignal.js | 28 +++++++++++-------- .../__tests__/AbortController-itest.js | 20 +++++++++++++ 3 files changed, 38 insertions(+), 15 deletions(-) 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 2dcac12de310..f093e25ee54e 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 @@ -72,10 +72,7 @@ function getSignal(controller: AbortController): AbortSignal { const signal = controller[SIGNAL_KEY]; if (signal == null) { throw new TypeError( - `Expected 'this' to be an 'AbortController' object, but got ${ - // $FlowExpectedError[invalid-compare] - controller === null ? 'null' : typeof controller - }`, + `Expected 'this' to be an 'AbortController' object, but got ${typeof controller}`, ); } return signal; 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 d45a8dae2e1e..9b0e42cd30db 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 @@ -91,30 +91,36 @@ export class AbortSignal extends EventTarget { throw new TypeError('The signals value must be an instance of Array'); } - const controller = new AbortController(); - const listeners = []; - const cleanup = () => listeners.forEach(unsubscribe => unsubscribe()); - + // First pass: validate that every item is an AbortSignal. 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 TypeError( 'The "signals[' + i + ']" argument must be an instance of AbortSignal', ); } + } - // Abort immediately if one of the signals is already aborted + // Second pass: short-circuit if any of the signals is already aborted. No + // listeners have been registered yet, so there is nothing to clean up if + // we return early. + for (let i = 0; i < signals.length; i++) { + const signal = signals[i]; if (signal.aborted) { - cleanup(); // Remove all listeners added so far - controller.abort(signal.reason); - break; + return AbortSignal.abort(signal.reason); } + } + + // Third pass: none of the signals is aborted, so subscribe to all of them + // and abort the resulting signal when any of them aborts. + const controller = new AbortController(); + const listeners = []; + const cleanup = () => listeners.forEach(unsubscribe => unsubscribe()); + for (let i = 0; i < signals.length; i++) { + const signal = signals[i]; const onAbort = () => { controller.abort(signal.reason); cleanup(); diff --git a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortController-itest.js b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortController-itest.js index 81a80f907aaf..4d5fe296a3f6 100644 --- a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortController-itest.js +++ b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortController-itest.js @@ -373,6 +373,26 @@ describe('AbortController', () => { 'The "signals[1]" argument must be an instance of AbortSignal', ); }); + + it('should validate every element before short-circuiting on an already-aborted signal', () => { + const aborted = new AbortController(); + aborted.abort(new Error('already aborted')); + + let thrown; + try { + // $FlowExpectedError[incompatible-type] + AbortSignal.any([aborted.signal, {}]); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(TypeError); + // $FlowExpectedError[incompatible-type] + const typeError: TypeError = thrown; + expect(typeError.message).toBe( + 'The "signals[1]" argument must be an instance of AbortSignal', + ); + }); }); describe("'timeout' static method", () => {