From 63fccb82bcd08dcf0a4a8affd0f8d7525b8e6f1b Mon Sep 17 00:00:00 2001 From: Ahmed Awaad Date: Wed, 7 Oct 2026 15:29:03 +0300 Subject: [PATCH] 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;