From 63fccb82bcd08dcf0a4a8affd0f8d7525b8e6f1b Mon Sep 17 00:00:00 2001 From: Ahmed Awaad Date: Wed, 7 Oct 2026 15:29:03 +0300 Subject: [PATCH 1/2] Add useScreenDimensions hook --- .../__tests__/useScreenDimensions-test.js | 103 ++++++++++++++++++ .../Utilities/useScreenDimensions.js | 53 +++++++++ packages/react-native/ReactNativeApi.d.ts | 4 +- packages/react-native/__typetests__/index.tsx | 6 + packages/react-native/index.js | 3 + packages/react-native/index.js.flow | 1 + .../Libraries/Utilities/Dimensions.d.ts | 3 + 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js create mode 100644 packages/react-native/Libraries/Utilities/useScreenDimensions.js diff --git a/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js new file mode 100644 index 000000000000..c4efef21c486 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js @@ -0,0 +1,103 @@ +/** + * 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 type {ReactTestRenderer} from 'react-test-renderer'; + +import Dimensions from '../Dimensions'; +import useScreenDimensions from '../useScreenDimensions'; +import {create, unmount} from '@react-native/jest-preset/jest/renderer'; +import * as React from 'react'; +import {act} from 'react-test-renderer'; + +describe('useScreenDimensions', () => { + const window = {width: 320, height: 480, scale: 2, fontScale: 1}; + const screen = {width: 400, height: 800, scale: 2, fontScale: 1}; + let renderer: ?ReactTestRenderer; + let observed; + let renderCount; + let originalDimensions; + + function TestComponent() { + observed = useScreenDimensions(); + renderCount++; + return null; + } + + beforeEach(() => { + originalDimensions = { + window: Dimensions.get('window'), + screen: Dimensions.get('screen'), + }; + Dimensions.set({window, screen}); + renderCount = 0; + }); + + afterEach(async () => { + if (renderer != null) { + await unmount(renderer); + renderer = null; + } + jest.restoreAllMocks(); + Dimensions.set(originalDimensions); + }); + + it('returns screen metrics rather than window metrics', async () => { + renderer = await create(); + expect(observed).toEqual(screen); + expect(observed).not.toEqual(window); + }); + + it.each([ + ['width', 800], + ['height', 400], + ['scale', 3], + ['fontScale', 1.5], + ])('updates when %s changes', async (property, value) => { + renderer = await create(); + const nextScreen = {...screen, [property]: value}; + await act(() => { + Dimensions.set({window, screen: nextScreen}); + }); + expect(observed).toEqual(nextScreen); + }); + + it('does not render again for window-only changes or identical screen metrics', async () => { + renderer = await create(); + const previousRenderCount = renderCount; + const previousDimensions = observed; + await act(() => { + Dimensions.set({window: {...window, width: 200}, screen: {...screen}}); + }); + expect(renderCount).toBe(previousRenderCount); + expect(observed).toBe(previousDimensions); + }); + + it('catches changes between render and subscription', async () => { + const nextScreen = {...screen, width: 800, height: 400}; + function UpdateBeforeSubscription() { + React.useLayoutEffect(() => { + Dimensions.set({window, screen: nextScreen}); + }, []); + return ; + } + renderer = await create(); + expect(observed).toEqual(nextScreen); + }); + + it('removes its subscription on unmount', async () => { + const addEventListener = jest.spyOn(Dimensions, 'addEventListener'); + renderer = await create(); + const subscription = addEventListener.mock.results[0].value; + const remove = jest.spyOn(subscription, 'remove'); + await unmount(renderer); + renderer = null; + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native/Libraries/Utilities/useScreenDimensions.js b/packages/react-native/Libraries/Utilities/useScreenDimensions.js new file mode 100644 index 000000000000..2f040336997e --- /dev/null +++ b/packages/react-native/Libraries/Utilities/useScreenDimensions.js @@ -0,0 +1,53 @@ +/** + * 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 Dimensions from './Dimensions'; +import { + type DisplayMetrics, + type DisplayMetricsAndroid, +} from './NativeDeviceInfo'; +import {useEffect, useState} from 'react'; + +/** + * React hook that provides the screen's width, height, scale, and + * font scale. Automatically updates when screen size or font scale changes. + * + * Width and height are reported in logical pixels, as in `Dimensions.get('screen')`. + */ +export default function useScreenDimensions(): + DisplayMetrics | DisplayMetricsAndroid { + const [dimensions, setDimensions] = useState(() => Dimensions.get('screen')); + useEffect(() => { + function handleChange({ + screen, + }: Readonly<{ + screen: DisplayMetrics | DisplayMetricsAndroid, + ... + }>) { + if ( + dimensions.width !== screen.width || + dimensions.height !== screen.height || + dimensions.scale !== screen.scale || + dimensions.fontScale !== screen.fontScale + ) { + setDimensions(screen); + } + } + const subscription = Dimensions.addEventListener('change', handleChange); + // We might have missed an update between calling `get` in render and + // `addEventListener` in this handler, so we set it here. If there was + // no change, React will filter out this update as a no-op. + handleChange({screen: Dimensions.get('screen')}); + return () => { + subscription.remove(); + }; + }, [dimensions]); + return dimensions; +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 2891dd53661e..f720668b276b 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<<3ce9599aceb36dd1f09ded3db3332daa>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5437,6 +5437,7 @@ declare function useColorScheme(): ColorSchemeName | null declare function usePressability( config: null | PressabilityConfig | undefined, ): null | PressabilityEventHandlers +declare function useScreenDimensions(): DisplayMetrics | DisplayMetricsAndroid declare function useWindowDimensions(): DisplayMetrics | DisplayMetricsAndroid declare type UTFSequence = typeof UTFSequence declare type Value = null | { @@ -6068,5 +6069,6 @@ export { useAnimatedValueXY, // dc9057ea useColorScheme, // d585efdb usePressability, // af291ad3 + useScreenDimensions, // a8bd8ad8 useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx index 54ea8b7694ba..1708da7a3a0c 100644 --- a/packages/react-native/__typetests__/index.tsx +++ b/packages/react-native/__typetests__/index.tsx @@ -121,6 +121,7 @@ import { requireNativeComponent, useColorScheme, useWindowDimensions, + useScreenDimensions, // @ts-ignore SectionListData, ToastAndroid, @@ -165,6 +166,11 @@ function testDimensions() { subscription.remove(); } +function TestUseScreenDimensions() { + const {width, height, scale, fontScale} = useScreenDimensions(); + const metrics: number[] = [width, height, scale, fontScale]; +} + function TextUseWindowDimensions() { const {width, height, scale, fontScale} = useWindowDimensions(); } diff --git a/packages/react-native/index.js b/packages/react-native/index.js index 08c71232d66a..06038f4d1a74 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -386,6 +386,9 @@ module.exports = { get usePressability() { return require('./Libraries/Pressability/usePressability').default; }, + get useScreenDimensions() { + return require('./Libraries/Utilities/useScreenDimensions').default; + }, get useWindowDimensions() { return require('./Libraries/Utilities/useWindowDimensions').default; }, diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 1886486c571c..ee442b7cdebf 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -457,6 +457,7 @@ export type { } from './Libraries/Pressability/Pressability'; export {default as usePressability} from './Libraries/Pressability/usePressability'; export {default as useColorScheme} from './Libraries/Utilities/useColorScheme'; +export {default as useScreenDimensions} from './Libraries/Utilities/useScreenDimensions'; export {default as useWindowDimensions} from './Libraries/Utilities/useWindowDimensions'; export {default as UTFSequence} from './Libraries/UTFSequence'; export {default as Vibration} from './Libraries/Vibration/Vibration'; 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..47c64cfa8f00 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts @@ -76,4 +76,7 @@ export interface Dimensions { export function useWindowDimensions(): ScaledSize; +/** Returns screen dimensions and updates when they change. */ +export function useScreenDimensions(): ScaledSize; + export const Dimensions: Dimensions; From fdd4faedfe621254d910302aeab04aa011efa310 Mon Sep 17 00:00:00 2001 From: Ahmed Awaad Date: Fri, 9 Oct 2026 11:33:26 +0300 Subject: [PATCH 2/2] Refactor useScreenDimensions hook to utilize useSyncExternalStore for improved performance and subscription management. Update tests to verify subscription behavior across screen updates and ensure correct metric reading when unmounted. --- .../__tests__/useScreenDimensions-test.js | 28 ++++++++++ .../Utilities/useScreenDimensions.js | 53 +++++++++---------- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js index c4efef21c486..0fac3d635a20 100644 --- a/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js +++ b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js @@ -100,4 +100,32 @@ describe('useScreenDimensions', () => { renderer = null; expect(remove).toHaveBeenCalledTimes(1); }); + + it('keeps its subscription across screen updates', async () => { + const addEventListener = jest.spyOn(Dimensions, 'addEventListener'); + renderer = await create(); + const subscription = addEventListener.mock.results[0].value; + const remove = jest.spyOn(subscription, 'remove'); + for (const width of [600, 800]) { + await act(() => { + Dimensions.set({window, screen: {...screen, width}}); + }); + expect(observed).toEqual({...screen, width}); + } + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(remove).not.toHaveBeenCalled(); + await unmount(renderer); + renderer = null; + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('reads current metrics after changes while unmounted', async () => { + renderer = await create(); + await unmount(renderer); + renderer = null; + const nextScreen = {...screen, width: 800}; + Dimensions.set({window, screen: nextScreen}); + renderer = await create(); + expect(observed).toEqual(nextScreen); + }); }); diff --git a/packages/react-native/Libraries/Utilities/useScreenDimensions.js b/packages/react-native/Libraries/Utilities/useScreenDimensions.js index 2f040336997e..8073ecd99709 100644 --- a/packages/react-native/Libraries/Utilities/useScreenDimensions.js +++ b/packages/react-native/Libraries/Utilities/useScreenDimensions.js @@ -13,7 +13,30 @@ import { type DisplayMetrics, type DisplayMetricsAndroid, } from './NativeDeviceInfo'; -import {useEffect, useState} from 'react'; +import {useSyncExternalStore} from 'react'; + +const subscribe = (onStoreChange: () => void) => { + const subscription = Dimensions.addEventListener('change', onStoreChange); + return () => subscription.remove(); +}; + +let cachedScreen: DisplayMetrics | DisplayMetricsAndroid | void; + +function getSnapshot(): DisplayMetrics | DisplayMetricsAndroid { + const screen = Dimensions.get('screen'); + // Dimensions can emit a new screen object without changing its metrics. + // Preserve snapshot identity so those events do not trigger a render. + if ( + cachedScreen == null || + cachedScreen.width !== screen.width || + cachedScreen.height !== screen.height || + cachedScreen.scale !== screen.scale || + cachedScreen.fontScale !== screen.fontScale + ) { + cachedScreen = screen; + } + return cachedScreen; +} /** * React hook that provides the screen's width, height, scale, and @@ -23,31 +46,5 @@ import {useEffect, useState} from 'react'; */ export default function useScreenDimensions(): DisplayMetrics | DisplayMetricsAndroid { - const [dimensions, setDimensions] = useState(() => Dimensions.get('screen')); - useEffect(() => { - function handleChange({ - screen, - }: Readonly<{ - screen: DisplayMetrics | DisplayMetricsAndroid, - ... - }>) { - if ( - dimensions.width !== screen.width || - dimensions.height !== screen.height || - dimensions.scale !== screen.scale || - dimensions.fontScale !== screen.fontScale - ) { - setDimensions(screen); - } - } - const subscription = Dimensions.addEventListener('change', handleChange); - // We might have missed an update between calling `get` in render and - // `addEventListener` in this handler, so we set it here. If there was - // no change, React will filter out this update as a no-op. - handleChange({screen: Dimensions.get('screen')}); - return () => { - subscription.remove(); - }; - }, [dimensions]); - return dimensions; + return useSyncExternalStore(subscribe, getSnapshot); }