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 e7fac1f26d7b..cab0573a5c1b 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 @@ -44,6 +44,12 @@ import {AbortController} from './AbortController'; const ABORTED_KEY: symbol = Symbol('aborted'); const REASON_KEY: symbol = Symbol('reason'); +const dependentSignals: WeakMap> = new WeakMap(); +const sourceSignalRefs: WeakMap< + AbortSignal, + ReadonlyArray>, +> = new WeakMap(); + /** * The signal class. * @see https://dom.spec.whatwg.org/#abortsignal @@ -113,22 +119,37 @@ export class AbortSignal extends EventTarget { } } - // 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()); + const resultSignal = createAbortSignal(); + const resultSources: Set = new Set(); for (let i = 0; i < signals.length; i++) { const signal = signals[i]; - const onAbort = () => { - controller.abort(signal.reason); - cleanup(); - }; - signal.addEventListener('abort', onAbort); - listeners.push(() => signal.removeEventListener('abort', onAbort)); + const sourceRefs = sourceSignalRefs.get(signal); + if (sourceRefs == null) { + resultSources.add(signal); + } else { + for (const sourceRef of sourceRefs) { + const sourceSignal = sourceRef.deref(); + if (sourceSignal != null) { + resultSources.add(sourceSignal); + } + } + } } - return controller.signal; + + const resultSourceRefs: Array> = []; + for (const sourceSignal of resultSources) { + let dependents = dependentSignals.get(sourceSignal); + if (dependents == null) { + dependents = new Set(); + dependentSignals.set(sourceSignal, dependents); + } + dependents.add(resultSignal); + resultSourceRefs.push(new WeakRef(sourceSignal)); + } + sourceSignalRefs.set(resultSignal, resultSourceRefs); + + return resultSignal; } // $FlowExpectedError[unsupported-syntax] @@ -201,7 +222,43 @@ export function abortSignal( signal[ABORTED_KEY] = true; // $FlowExpectedError[prop-missing] signal[REASON_KEY] = reason; + + const dependentSignalsToAbort: Array = []; + const dependents = dependentSignals.get(signal); + if (dependents != null) { + dependentSignals.delete(signal); + for (const dependentSignal of dependents) { + // $FlowExpectedError[invalid-compare] + // $FlowExpectedError[prop-missing] + if (dependentSignal[ABORTED_KEY] === false) { + // $FlowExpectedError[prop-missing] + dependentSignal[ABORTED_KEY] = true; + // $FlowExpectedError[prop-missing] + dependentSignal[REASON_KEY] = reason; + unregisterDependentSignal(dependentSignal); + dependentSignalsToAbort.push(dependentSignal); + } + } + } + dispatchTrustedEvent(signal, new Event('abort')); + for (const dependentSignal of dependentSignalsToAbort) { + dispatchTrustedEvent(dependentSignal, new Event('abort')); + } +} + +function unregisterDependentSignal(dependentSignal: AbortSignal): void { + const sourceRefs = sourceSignalRefs.get(dependentSignal); + if (sourceRefs == null) { + return; + } + sourceSignalRefs.delete(dependentSignal); + for (const sourceRef of sourceRefs) { + const sourceSignal = sourceRef.deref(); + if (sourceSignal != null) { + dependentSignals.get(sourceSignal)?.delete(dependentSignal); + } + } } // Properties should be enumerable. 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 4e8cf7dc7811..a0fe3814384c 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 @@ -349,15 +349,17 @@ describe('AbortController', () => { const second = new AbortController(); const reason = new Error('stop'); - const signal = AbortSignal.any([first.signal, second.signal]); + Fantom.runTask(() => { + const signal = AbortSignal.any([first.signal, second.signal]); - expect(signal).toBeInstanceOf(AbortSignal); - expect(signal.aborted).toBe(false); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); - second.abort(reason); + second.abort(reason); - expect(signal.aborted).toBe(true); - expect(signal.reason).toBe(reason); + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe(reason); + }); }); it('should abort immediately if one of the provided signals is already aborted', () => { diff --git a/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortSignal-any-test.js b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortSignal-any-test.js new file mode 100644 index 000000000000..4345b45bbad2 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/abort-api/__tests__/AbortSignal-any-test.js @@ -0,0 +1,82 @@ +/** + * 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 {AbortController} from '../AbortController'; +import {AbortSignal} from '../AbortSignal'; + +describe('AbortSignal.any', () => { + it('aborts the dependent signal even if a source listener stops propagation', () => { + const source = new AbortController(); + source.signal.addEventListener('abort', event => { + event.stopImmediatePropagation(); + }); + const signal = AbortSignal.any([source.signal]); + + source.abort('cancelled'); + + expect(signal.aborted).toBe(true); + expect(signal.reason).toBe('cancelled'); + }); + + it('marks dependent signals as aborted before dispatching to the source', () => { + const source = new AbortController(); + let observed; + source.signal.addEventListener('abort', () => { + observed = [signal.aborted, signal.reason]; + }); + const signal = AbortSignal.any([source.signal]); + + source.abort('reason'); + + expect(observed).toEqual([true, 'reason']); + }); + + it('dispatches to the source and then to its dependents in creation order', () => { + const first = new AbortController(); + const second = new AbortController(); + const inner = AbortSignal.any([first.signal, second.signal]); + const outer = AbortSignal.any([inner, inner, first.signal]); + const unrelated = AbortSignal.any([second.signal]); + const events: Array = []; + first.signal.addEventListener('abort', () => { + events.push('first'); + }); + inner.addEventListener('abort', () => { + events.push('inner'); + }); + outer.addEventListener('abort', () => { + events.push('outer'); + }); + unrelated.addEventListener('abort', () => { + events.push('unrelated'); + }); + + first.abort('first reason'); + second.abort('second reason'); + + expect(events).toEqual(['first', 'inner', 'outer', 'unrelated']); + expect(outer.reason).toBe('first reason'); + expect(unrelated.reason).toBe('second reason'); + }); + + it('keeps the first reason when a source listener aborts another source', () => { + const first = new AbortController(); + const second = new AbortController(); + first.signal.addEventListener('abort', () => second.abort('second')); + const signal = AbortSignal.any([first.signal, second.signal]); + const listener = jest.fn(); + signal.addEventListener('abort', listener); + + first.abort('first'); + + expect(signal.reason).toBe('first'); + expect(listener).toHaveBeenCalledTimes(1); + }); +});