From a89df8633c07560f9aa38bbd3e52804854e74703 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:50:12 +0200 Subject: [PATCH 1/2] feat: Replace EventEmitter with EventTarget in Dimensions --- .../Libraries/Utilities/Dimensions.js | 20 ++-- .../Utilities/__tests__/Dimensions-itest.js | 91 ++++++++++++++----- packages/react-native/__typetests__/index.tsx | 7 ++ .../Libraries/Utilities/Dimensions.d.ts | 1 + 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/packages/react-native/Libraries/Utilities/Dimensions.js b/packages/react-native/Libraries/Utilities/Dimensions.js index 3e5dc8385cb5..b43948374281 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.js +++ b/packages/react-native/Libraries/Utilities/Dimensions.js @@ -8,10 +8,13 @@ * @format */ +import type {EventSubscription} from '../vendor/emitter/EventEmitter'; + +import Event from '../../src/private/webapis/dom/events/Event'; +import EventTarget, { + type AddEventListenerOptions, +} from '../../src/private/webapis/dom/events/EventTarget'; import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter'; -import EventEmitter, { - type EventSubscription, -} from '../vendor/emitter/EventEmitter'; import NativeDeviceInfo, { type DimensionsPayload, type DisplayMetrics, @@ -24,9 +27,7 @@ export type {DimensionsPayload, DisplayMetrics, DisplayMetricsAndroid}; /** @deprecated Use DisplayMetrics */ export type ScaledSize = DisplayMetrics; -const eventEmitter = new EventEmitter<{ - change: [DimensionsPayload], -}>(); +const eventTarget = new EventTarget(); let dimensionsInitialized = false; let dimensions: DimensionsPayload; @@ -94,7 +95,7 @@ class Dimensions { dimensions = {window, screen}; if (dimensionsInitialized) { // Don't fire 'change' the first time the dimensions are set. - eventEmitter.emit('change', dimensions); + eventTarget.dispatchEvent(new Event('change')); } else { dimensionsInitialized = true; } @@ -112,13 +113,16 @@ class Dimensions { static addEventListener( type: 'change', handler: LegacyFunction, + options?: Pick, ): EventSubscription { invariant( type === 'change', 'Trying to subscribe to unknown event: "%s"', type, ); - return eventEmitter.addListener(type, handler); + const onChange = () => handler(dimensions); + eventTarget.addEventListener(type, onChange, options); + return {remove: () => eventTarget.removeEventListener(type, onChange)}; } } diff --git a/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js index e4481e8f8be1..14ab55269dd7 100644 --- a/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js +++ b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-itest.js @@ -13,15 +13,18 @@ import Dimensions from '../Dimensions'; import Platform from '../Platform'; describe('Dimensions', () => { + const dimensions = { + width: 400, + height: 800, + scale: 2, + densityDpi: 2, + fontScale: 3, + }; + it('should set window dimensions', () => { + const newDimensions = {...dimensions}; Dimensions.set({ - windowPhysicalPixels: { - width: 400, - height: 800, - scale: 2, - densityDpi: 2, - fontScale: 3, - }, + windowPhysicalPixels: newDimensions, }); expect(Dimensions.get('window').width).toEqual(200); @@ -33,16 +36,10 @@ describe('Dimensions', () => { it('should set screen dimensions on Android', () => { // $FlowFixMe[incompatible-type] - `Platform.OS` needs to be read-only. Platform.OS = 'android'; - const dimensions = { - width: 400, - height: 800, - scale: 2, - densityDpi: 2, - fontScale: 3, - }; + const newDimensions = {...dimensions}; Dimensions.set({ - windowPhysicalPixels: dimensions, - screenPhysicalPixels: dimensions, + windowPhysicalPixels: newDimensions, + screenPhysicalPixels: newDimensions, }); expect(Dimensions.get('screen').width).toEqual(200); @@ -54,17 +51,65 @@ describe('Dimensions', () => { it('should set screen dimensions on iOS', () => { // $FlowFixMe[incompatible-type] - `Platform.OS` needs to be read-only. Platform.OS = 'ios'; - const dimensions = { - width: 400, - height: 800, - scale: 2, - densityDpi: 2, - fontScale: 3, - }; Dimensions.set({ windowPhysicalPixels: dimensions, }); expect(Dimensions.get('screen')).toEqual(Dimensions.get('window')); }); + + it('should call a listener on each dimension change', () => { + const listener = jest.fn(); + const newDimensions1 = {...dimensions, width: 10}; + const newDimensions2 = {...dimensions, width: 30}; + const sub = Dimensions.addEventListener('change', listener); + + Dimensions.set({windowPhysicalPixels: newDimensions1}); + Dimensions.set({windowPhysicalPixels: newDimensions2}); + + expect(listener).toBeCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, { + screen: {fontScale: 3, height: 400, scale: 2, width: 5}, + window: {fontScale: 3, height: 400, scale: 2, width: 5}, + }); + expect(listener).toHaveBeenNthCalledWith(2, { + screen: {fontScale: 3, height: 400, scale: 2, width: 15}, + window: {fontScale: 3, height: 400, scale: 2, width: 15}, + }); + sub.remove(); + }); + + it('should call a listener once', () => { + const listener = jest.fn(); + const newDimensions1 = {...dimensions, fontScale: 1}; + const newDimensions2 = {...dimensions, fontScale: 2}; + Dimensions.addEventListener('change', listener, {once: true}); + + Dimensions.set({windowPhysicalPixels: newDimensions1}); + Dimensions.set({windowPhysicalPixels: newDimensions2}); + + expect(listener).toBeCalledTimes(1); + expect(listener).toBeCalledWith({ + screen: {fontScale: 1, height: 400, scale: 2, width: 200}, + window: {fontScale: 1, height: 400, scale: 2, width: 200}, + }); + }); + + it('should remove a listener on a signal abort', () => { + const listener = jest.fn(); + const newDimensions1 = {...dimensions, fontScale: 1}; + const newDimensions2 = {...dimensions, fontScale: 2}; + const c = new AbortController(); + Dimensions.addEventListener('change', listener, {signal: c.signal}); + + Dimensions.set({windowPhysicalPixels: newDimensions2}); + c.abort(); // remove listener + Dimensions.set({windowPhysicalPixels: newDimensions1}); + + expect(listener).toBeCalledTimes(1); + expect(listener).toBeCalledWith({ + screen: {fontScale: 2, height: 400, scale: 2, width: 200}, + window: {fontScale: 2, height: 400, scale: 2, width: 200}, + }); + }); }); diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..63578dbb3b25 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -163,6 +163,13 @@ function testDimensions() { dimensionsListener, ); subscription.remove(); + + Dimensions.addEventListener('change', dimensionsListener, { + once: true, + }); + Dimensions.addEventListener('change', dimensionsListener, { + signal: AbortSignal.timeout(5000), + }); } function TextUseWindowDimensions() { diff --git a/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts index cca9d8189f53..b49366ea73ce 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts @@ -71,6 +71,7 @@ export interface Dimensions { window: ScaledSize; screen: ScaledSize; }) => void, + options?: {once?: true | undefined; signal?: AbortSignal | undefined}, ): EmitterSubscription; } From dc3b736df8043aa300ea16e1afa6903b0cff7f89 Mon Sep 17 00:00:00 2001 From: D N <4661784+retyui@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:49:55 +0200 Subject: [PATCH 2/2] fix: Add global `AbortSignal` to fix the `build-types` command --- packages/react-native/ReactNativeApi.d.ts | 20 ++++++++++++++++--- .../js-api/build-types/buildApiSnapshot.js | 6 ++++++ .../build-types/templates/api-extractor.json | 1 + .../js-api/build-types/templates/globals.d.ts | 11 ++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 scripts/js-api/build-types/templates/globals.d.ts diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..252a80694dcc 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<<3d7835eb88d765da6d5de7f92764d0af>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1113,6 +1113,13 @@ declare type add = typeof add declare function addChangeListener( listener: (preferences: AppearancePreferences) => void, ): EventSubscription +declare type AddEventListenerOptions = Readonly< + EventListenerOptions & { + once?: boolean + passive?: boolean + signal?: AbortSignal + } +> declare class Alert { static alert( title: null | string | undefined, @@ -1874,7 +1881,11 @@ declare type DevMenuStatic = { declare type DevSettings = typeof DevSettings declare type diffClamp = typeof diffClamp declare class Dimensions { - static addEventListener(type: "change", handler: Function): EventSubscription + static addEventListener( + type: "change", + handler: Function, + options?: Pick, + ): EventSubscription static get(dim: string): DisplayMetrics | DisplayMetricsAndroid static set(dims: Readonly): void } @@ -2116,6 +2127,9 @@ declare class EventEmitter< declare type EventEmitter_2 = ( handler: ($$PARAM_0$$: T) => Promise | void, ) => EventSubscription +declare type EventListenerOptions = { + readonly capture?: boolean +} declare type EventName = (() => string) | string declare interface EventSubscription { remove(): void @@ -5816,7 +5830,7 @@ export { DeviceInfo, // 0f5a517b DeviceInfoConstants, // 279e7858 DimensionValue, // b163a381 - Dimensions, // 980ef68c + Dimensions, // 03d31826 DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb diff --git a/scripts/js-api/build-types/buildApiSnapshot.js b/scripts/js-api/build-types/buildApiSnapshot.js index 2816690973ea..7ea23fd3a8d7 100644 --- a/scripts/js-api/build-types/buildApiSnapshot.js +++ b/scripts/js-api/build-types/buildApiSnapshot.js @@ -303,6 +303,12 @@ async function generateConfigFiles(tempDirectory: string) { adjustedApiExtractorConfig, ); + // copy ambient global declarations + await fs.copyFile( + path.join(__dirname, 'templates', 'globals.d.ts'), + path.join(tempDirectory, 'globals.d.ts'), + ); + // generate basic package.json const packageJSON = {name: 'react-native'}; await fs.writeFile( diff --git a/scripts/js-api/build-types/templates/api-extractor.json b/scripts/js-api/build-types/templates/api-extractor.json index d9215e1691ee..5956d467c885 100644 --- a/scripts/js-api/build-types/templates/api-extractor.json +++ b/scripts/js-api/build-types/templates/api-extractor.json @@ -5,6 +5,7 @@ "compiler": { "overrideTsconfig": { "$schema": "http://json.schemastore.org/tsconfig", + "files": ["${typegen_directory}/globals.d.ts"], "compilerOptions": { "lib": ["es2020"], "types": [], diff --git a/scripts/js-api/build-types/templates/globals.d.ts b/scripts/js-api/build-types/templates/globals.d.ts new file mode 100644 index 000000000000..481df689bb8a --- /dev/null +++ b/scripts/js-api/build-types/templates/globals.d.ts @@ -0,0 +1,11 @@ +/** + * 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. + */ + +// Ambient web globals referenced by the public API that are not part of the +// "es2020" lib used by api-extractor. Declared here so that api-extractor +// resolves them as globals and leaves references to them untouched. +declare class AbortSignal {}