diff --git a/packages/react-native/Libraries/AppState/AppState.d.ts b/packages/react-native/Libraries/AppState/AppState.d.ts index 6333c8190565ce..23218cc86ed381 100644 --- a/packages/react-native/Libraries/AppState/AppState.d.ts +++ b/packages/react-native/Libraries/AppState/AppState.d.ts @@ -8,6 +8,7 @@ */ import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter'; +import {AddEventListenerOptions} from '../EventEmitter/EventTargetLike'; /** * AppState can tell you if the app is in the foreground or background, @@ -51,6 +52,7 @@ export interface AppStateStatic { addEventListener( type: AppStateEvent, listener: (state: AppStateStatus) => void, + options?: AddEventListenerOptions, ): NativeEventSubscription; } diff --git a/packages/react-native/Libraries/AppState/AppState.js b/packages/react-native/Libraries/AppState/AppState.js index b1456ec048e2f6..95b7a860669c3d 100644 --- a/packages/react-native/Libraries/AppState/AppState.js +++ b/packages/react-native/Libraries/AppState/AppState.js @@ -8,6 +8,7 @@ * @format */ +import {adaptToEventTarget} from '../EventEmitter/EventTargetLike'; import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; import logError from '../Utilities/logError'; import Platform from '../Utilities/Platform'; @@ -104,13 +105,26 @@ class AppStateImpl { } } + addEventListener( + type: K, + handler: (...AppStateEventDefinitions[K]) => void, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, + ): EventSubscription { + return adaptToEventTarget( + (...args) => this._addEventListener(...args), + type, + handler, + options, + ); + } + /** * Add a handler to AppState changes by listening to the `change` event type * and providing the handler. * * See https://reactnative.dev/docs/appstate#addeventlistener */ - addEventListener( + _addEventListener( type: K, handler: (...AppStateEventDefinitions[K]) => void, ): EventSubscription { diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts index bad75fca1346b6..5eb60016cd0d36 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.d.ts @@ -9,6 +9,7 @@ import {HostInstance} from '../../../types/public/ReactNativeTypes'; import {EmitterSubscription} from '../../vendor/emitter/EventEmitter'; +import {AddEventListenerOptions} from '../../EventEmitter/EventTargetLike'; type AccessibilityChangeEventName = | 'change' // deprecated, maps to screenReaderChanged @@ -129,10 +130,12 @@ export interface AccessibilityInfoStatic { addEventListener( eventName: AccessibilityChangeEventName, handler: AccessibilityChangeEventHandler, + options?: AddEventListenerOptions, ): EmitterSubscription; addEventListener( eventName: AccessibilityAnnouncementEventName, handler: AccessibilityAnnouncementFinishedEventHandler, + options?: AddEventListenerOptions, ): EmitterSubscription; /** diff --git a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js index 223f6149ddbc07..d852852cecc1ec 100644 --- a/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js +++ b/packages/react-native/Libraries/Components/AccessibilityInfo/AccessibilityInfo.js @@ -11,6 +11,7 @@ import type {HostInstance} from '../../../src/private/types/HostInstance'; import type {EventSubscription} from '../../vendor/emitter/EventEmitter'; +import {adaptToEventTarget} from '../../EventEmitter/EventTargetLike'; import RCTDeviceEventEmitter from '../../EventEmitter/RCTDeviceEventEmitter'; import {sendAccessibilityEvent} from '../../ReactNative/RendererProxy'; import Platform from '../../Utilities/Platform'; @@ -428,12 +429,18 @@ const AccessibilityInfo = { eventName: K, // $FlowFixMe[incompatible-type] - Flow bug with unions and generics (T128099423) handler: (...AccessibilityEventDefinitions[K]) => void, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, ): EventSubscription { const deviceEventName = EventNames.get(eventName); return deviceEventName == null ? {remove(): void {}} : // $FlowFixMe[incompatible-type] - RCTDeviceEventEmitter.addListener(deviceEventName, handler); + adaptToEventTarget( + (...args) => RCTDeviceEventEmitter.addListener(...args), + eventName, + handler, + options, + ); }, /** diff --git a/packages/react-native/Libraries/EventEmitter/EventTargetLike.d.ts b/packages/react-native/Libraries/EventEmitter/EventTargetLike.d.ts new file mode 100644 index 00000000000000..c806a546499a30 --- /dev/null +++ b/packages/react-native/Libraries/EventEmitter/EventTargetLike.d.ts @@ -0,0 +1,5 @@ + +export type AddEventListenerOptions = { + once?: boolean | undefined; + signal?: AbortSignal | undefined; +} diff --git a/packages/react-native/Libraries/EventEmitter/EventTargetLike.js b/packages/react-native/Libraries/EventEmitter/EventTargetLike.js new file mode 100644 index 00000000000000..0e9d70b607141e --- /dev/null +++ b/packages/react-native/Libraries/EventEmitter/EventTargetLike.js @@ -0,0 +1,73 @@ +/** + * @flow strict-local + * @format + */ + +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +/** + * EventTarget adapter + * + * Options supported: + * - `once` (boolean) - If true, the listener would be automatically removed when invoked + * - `signal` (AbortSignal) - The listener will be removed when the abort() method of the AbortController which owns the AbortSignal is called + * + * see: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ +export const adaptToEventTarget = < + R: EventSubscription | {remove(): void, ...}, +>( + // $FlowFixMe[unclear-type] + addEventListener: (...args: any[]) => R, + type: mixed, + listener: mixed, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, +): R => { + // Extract options to avoid mutation issues + // $FlowFixMe[incompatible-type] + const signal: ?AbortSignal = options?.signal; + const once = options?.once; + + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError( + "Failed to execute 'addEventListener': Failed to convert the 'signal' value to 'AbortSignal'.", + ); + } + + const subscription: R = addEventListener(type, (...args) => { + // $FlowFixMe[sketchy-null-bool] + if (once) { + subscription.remove(); + signal?.removeEventListener('abort', onAbort); + } + // $FlowFixMe[not-a-function] + return listener(...args); + }); + + // If already aborted, remove subscription immediately + if (signal?.aborted) { + subscription.remove(); + return subscription; + } + + // Remove subscription if the abort signal is triggered + const onAbort = () => subscription.remove(); + signal?.addEventListener('abort', onAbort, {once: true}); // Note: `once` option is supported by `event-target-shim` which is used by `abort-controller` polyfill + + // $FlowFixMe[incompatible-type] + return Object.create( + // $FlowFixMe[not-an-object] + subscription, + { + remove: { + writable: true, + enumerable: true, + configurable: true, + value: () => { + subscription.remove(); + signal?.removeEventListener('abort', onAbort); + }, + }, + }, + ); +}; diff --git a/packages/react-native/Libraries/Linking/Linking.d.ts b/packages/react-native/Libraries/Linking/Linking.d.ts index 55d7aa33cb5803..413e57d84de8bd 100644 --- a/packages/react-native/Libraries/Linking/Linking.d.ts +++ b/packages/react-native/Libraries/Linking/Linking.d.ts @@ -8,6 +8,7 @@ */ import {NativeEventEmitter} from '../EventEmitter/NativeEventEmitter'; +import {AddEventListenerOptions} from '../EventEmitter/EventTargetLike'; import {EmitterSubscription} from '../vendor/emitter/EventEmitter'; export interface LinkingImpl extends NativeEventEmitter { @@ -18,6 +19,7 @@ export interface LinkingImpl extends NativeEventEmitter { addEventListener( type: 'url', handler: (event: {url: string}) => void, + options?: AddEventListenerOptions, ): EmitterSubscription; /** diff --git a/packages/react-native/Libraries/Linking/Linking.js b/packages/react-native/Libraries/Linking/Linking.js index 3af2375e2bbc9c..af087fb69ee64e 100644 --- a/packages/react-native/Libraries/Linking/Linking.js +++ b/packages/react-native/Libraries/Linking/Linking.js @@ -10,6 +10,7 @@ import type {EventSubscription} from '../vendor/emitter/EventEmitter'; +import {adaptToEventTarget} from '../EventEmitter/EventTargetLike'; import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; import Platform from '../Utilities/Platform'; import NativeIntentAndroid from './NativeIntentAndroid'; @@ -35,8 +36,14 @@ class LinkingImpl extends NativeEventEmitter { addEventListener( eventType: K, listener: (...LinkingEventDefinitions[K]) => unknown, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, ): EventSubscription { - return this.addListener(eventType, listener); + return adaptToEventTarget( + (...args) => this.addListener(...args), + eventType, + listener, + options, + ); } /** diff --git a/packages/react-native/Libraries/Utilities/BackHandler.android.js b/packages/react-native/Libraries/Utilities/BackHandler.android.js index ff468f6500fcc8..6962439daa0ca7 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.android.js +++ b/packages/react-native/Libraries/Utilities/BackHandler.android.js @@ -9,6 +9,7 @@ */ import NativeDeviceEventManager from '../../Libraries/NativeModules/specs/NativeDeviceEventManager'; +import {adaptToEventTarget} from '../EventEmitter/EventTargetLike'; import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter'; const DEVICE_BACK_EVENT = 'hardwareBackPress'; @@ -59,8 +60,27 @@ type TBackHandler = { +addEventListener: ( eventName: BackPressEventName, handler: BackPressHandler, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, ) => {remove: () => void, ...}, }; + +const addListener = ( + eventName: BackPressEventName, + handler: BackPressHandler, +) => { + if (_backPressSubscriptions.indexOf(handler) === -1) { + _backPressSubscriptions.push(handler); + } + return { + remove: (): void => { + const index = _backPressSubscriptions.indexOf(handler); + if (index !== -1) { + _backPressSubscriptions.splice(index, 1); + } + }, + }; +}; + const BackHandler: TBackHandler = { exitApp: function (): void { if (!NativeDeviceEventManager) { @@ -78,18 +98,9 @@ const BackHandler: TBackHandler = { addEventListener: function ( eventName: BackPressEventName, handler: BackPressHandler, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, ): {remove: () => void, ...} { - if (_backPressSubscriptions.indexOf(handler) === -1) { - _backPressSubscriptions.push(handler); - } - return { - remove: (): void => { - const index = _backPressSubscriptions.indexOf(handler); - if (index !== -1) { - _backPressSubscriptions.splice(index, 1); - } - }, - }; + return adaptToEventTarget(addListener, eventName, handler, options); }, }; diff --git a/packages/react-native/Libraries/Utilities/BackHandler.d.ts b/packages/react-native/Libraries/Utilities/BackHandler.d.ts index 8ca8e1743d193c..634c4e156868c7 100644 --- a/packages/react-native/Libraries/Utilities/BackHandler.d.ts +++ b/packages/react-native/Libraries/Utilities/BackHandler.d.ts @@ -8,6 +8,7 @@ */ import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter'; +import {AddEventListenerOptions} from '../EventEmitter/EventTargetLike'; export type BackPressEventName = 'hardwareBackPress'; @@ -27,6 +28,7 @@ export interface BackHandlerStatic { addEventListener( eventName: BackPressEventName, handler: () => boolean | null | undefined, + options?: AddEventListenerOptions, ): NativeEventSubscription; } diff --git a/packages/react-native/Libraries/Utilities/Dimensions.d.ts b/packages/react-native/Libraries/Utilities/Dimensions.d.ts index cca9d8189f5317..9770a57de21791 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.d.ts +++ b/packages/react-native/Libraries/Utilities/Dimensions.d.ts @@ -8,6 +8,7 @@ */ import {EmitterSubscription} from '../vendor/emitter/EventEmitter'; +import {AddEventListenerOptions} from '../EventEmitter/EventTargetLike'; // Used by Dimensions below export interface ScaledSize { @@ -71,6 +72,7 @@ export interface Dimensions { window: ScaledSize; screen: ScaledSize; }) => void, + options?: AddEventListenerOptions, ): EmitterSubscription; } diff --git a/packages/react-native/Libraries/Utilities/Dimensions.js b/packages/react-native/Libraries/Utilities/Dimensions.js index d8d4b7d3391211..edf27ced506455 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.js +++ b/packages/react-native/Libraries/Utilities/Dimensions.js @@ -8,6 +8,7 @@ * @format */ +import {adaptToEventTarget} from '../EventEmitter/EventTargetLike'; import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter'; import EventEmitter, { type EventSubscription, @@ -106,13 +107,19 @@ class Dimensions { static addEventListener( type: 'change', handler: Function, + options?: ?{|once?: ?boolean, signal?: ?mixed|}, ): EventSubscription { invariant( type === 'change', 'Trying to subscribe to unknown event: "%s"', type, ); - return eventEmitter.addListener(type, handler); + return adaptToEventTarget( + (...args) => eventEmitter.addListener(...args), + type, + handler, + options, + ); } } diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 8793980356c080..16891ca651d098 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<9aef9aab912c81f793aa01691bc872cd>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -99,6 +99,13 @@ declare const AccessibilityInfo_default: { addEventListener( eventName: K, handler: (...$$REST$$: AccessibilityEventDefinitions[K]) => void, + options?: + | null + | undefined + | { + once?: boolean + signal?: unknown + }, ): EventSubscription announceForAccessibility(announcement: string): void announceForAccessibilityWithOptions( @@ -1682,6 +1689,13 @@ declare class AppStateImpl { addEventListener( type: K, handler: (...$$REST$$: AppStateEventDefinitions[K]) => void, + options?: + | null + | undefined + | { + once?: boolean + signal?: unknown + }, ): EventSubscription constructor() } @@ -2009,7 +2023,17 @@ declare type DialogOptions = { } declare type diffClamp = typeof diffClamp declare class Dimensions { - static addEventListener(type: "change", handler: Function): EventSubscription + static addEventListener( + type: "change", + handler: Function, + options?: + | null + | undefined + | { + once?: boolean + signal?: unknown + }, + ): EventSubscription static get(dim: string): DisplayMetrics | DisplayMetricsAndroid static set(dims: Readonly): void } @@ -3039,6 +3063,13 @@ declare class LinkingImpl extends NativeEventEmitter { addEventListener( eventType: K, listener: (...$$REST$$: LinkingEventDefinitions[K]) => unknown, + options?: + | null + | undefined + | { + once?: boolean + signal?: unknown + }, ): EventSubscription canOpenURL(url: string): Promise constructor() @@ -5982,7 +6013,7 @@ declare type WrapperComponentProvider = ( ) => React.ComponentType export { AccessibilityActionEvent, // f6181a2c - AccessibilityInfo, // 3e373fdc + AccessibilityInfo, // 490fa685 AccessibilityProps, // 5a2836fc AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 @@ -6000,7 +6031,7 @@ export { Animated, // ed7eb912 AppConfig, // ebddad4b AppRegistry, // 6cdee1d6 - AppState, // 12012be5 + AppState, // e85d1fe0 AppStateEvent, // 80f034c3 AppStateStatus, // 447e5ef2 Appearance, // 00cbaa0a @@ -6024,7 +6055,7 @@ export { DeviceInfo, // 521bfb71 DeviceInfoConstants, // 279e7858 DimensionValue, // b163a381 - Dimensions, // 980ef68c + Dimensions, // 48c269fb DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb @@ -6100,7 +6131,7 @@ export { LayoutChangeEvent, // c674f902 LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 - Linking, // 9a6a174d + Linking, // 9e12235e ListRenderItem, // b5353fd8 ListRenderItemInfo, // e8595b03 ListViewToken, // 833d3481