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..0fac3d635a20 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js @@ -0,0 +1,131 @@ +/** + * 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); + }); + + 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 new file mode 100644 index 000000000000..8073ecd99709 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/useScreenDimensions.js @@ -0,0 +1,50 @@ +/** + * 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 {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 + * 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 { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index a549b879b33d..eeb7a0f34991 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -5433,6 +5433,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 | { @@ -6065,5 +6066,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 79b601abb36d..4c4f198db168 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -389,6 +389,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 6214d033bc16..24e98abc05ce 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -460,6 +460,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;