diff --git a/packages/react-native/Libraries/Utilities/Dimensions.d.ts b/packages/react-native/Libraries/Utilities/Dimensions.d.ts index cca9d8189f53..bc94dcec0416 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.d.ts +++ b/packages/react-native/Libraries/Utilities/Dimensions.d.ts @@ -75,5 +75,6 @@ export interface Dimensions { } export function useWindowDimensions(): ScaledSize; +export function useWindowDimensions(selector: (state: ScaledSize) => T): T; export const Dimensions: Dimensions; diff --git a/packages/react-native/Libraries/Utilities/__tests__/useWindowDimensions-test.js b/packages/react-native/Libraries/Utilities/__tests__/useWindowDimensions-test.js new file mode 100644 index 000000000000..bd1dfdf1be21 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/useWindowDimensions-test.js @@ -0,0 +1,232 @@ +/** + * 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 View from '../../Components/View/View'; +import Dimensions from '../Dimensions'; +import { + type DisplayMetrics, + type DisplayMetricsAndroid, +} from '../NativeDeviceInfo'; +import useWindowDimensions from '../useWindowDimensions'; +import {useEffect} from 'react'; +import {act, create} from 'react-test-renderer'; + +type State = DisplayMetrics | DisplayMetricsAndroid; +type TestProps = { + selector?: (state: State) => number, + onResult?: (result: number | State) => void, + testID?: string, +}; +function TestView({selector, onResult}: TestProps) { + const result = useWindowDimensions(selector); + useEffect(() => { + onResult?.(result); + }, [onResult, result]); + return ; +} + +const defaultWindow = {fontScale: 2, height: 1334, scale: 2, width: 750}; + +describe('useWindowDimensions', () => { + const expectedDimensions = Dimensions.get('window'); + let cleanupFns = []; + + // Auto cleanup + afterEach(() => { + cleanupFns.forEach(fn => fn()); + cleanupFns = []; + }); + + const renderHook = (props?: TestProps) => { + let root; + const defaultProps: TestProps = {onResult: jest.fn(), selector: undefined}; + // Mount + act(() => { + root = create(); + }); + + const rerender = (newProps: TestProps) => { + act(() => { + root.update(); + }); + }; + const unmount = () => { + act(() => { + root.unmount(); + }); + }; + cleanupFns.push(unmount); // auto-cleanup + return {unmount, rerender}; + }; + + const mockGetWindow = () => { + const spy = jest.spyOn(Dimensions, 'get'); + cleanupFns.push(() => spy.mockRestore()); // auto-cleanup + return { + getWindow: spy, + }; + }; + const mockAddEventListener = () => { + const sub = {remove: jest.fn()}; + const spy = jest + .spyOn(Dimensions, 'addEventListener') + .mockImplementation(() => sub); + cleanupFns.push(() => spy.mockRestore()); // auto-cleanup + return { + addListener: spy, + removeListener: sub.remove, + // $FlowFixMe[unclear-type] + getListener: (): Function => spy.mock.calls.at(-1)?.at(1), // `-1` - last call, `1` - second argument + }; + }; + + it('should cleanup a listener on a component unmount', () => { + // Arrange + const {addListener, removeListener} = mockAddEventListener(); + + const {unmount} = renderHook(); + + expect(addListener).toHaveBeenCalledTimes(1); + expect(addListener).toHaveBeenCalledWith('change', expect.any(Function)); + + // Act + unmount(); + + // Assert + expect(removeListener).toHaveBeenCalledTimes(1); + expect(removeListener).toHaveBeenCalledWith(); + }); + + it('should return the current window dimensions on mount', () => { + // Arrange + const onResult = jest.fn(); + + // Act + renderHook({onResult}); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expectedDimensions); + expect(expectedDimensions).toStrictEqual(defaultWindow); + }); + + it('should return the same object on re-render', () => { + // Arrange + const onResult = jest.fn(); + + const {rerender} = renderHook({onResult}); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expectedDimensions); + expect(expectedDimensions).toStrictEqual(defaultWindow); + + // Act + rerender({testID: 'test-123'}); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + }); + + it('should not re-render when screen dimension has changed but window is the same', () => { + // Arrange + const {getListener} = mockAddEventListener(); + const onResult = jest.fn(); + renderHook({onResult}); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expectedDimensions); + expect(expectedDimensions).toStrictEqual(defaultWindow); + + // Act + const listener = getListener(); + act(() => { + listener({ + window: {...expectedDimensions}, + screen: {...expectedDimensions, height: 1000}, + }); + }); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + }); + + describe('selector argument', () => { + it('should return partial of state', () => { + const onResult = jest.fn(); + + renderHook({onResult, selector: state => state.height}); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expectedDimensions.height); + }); + + it('should re-render if selected value has changed', () => { + // Arrange + const newHeight = 666; + const onResult = jest.fn(); + const {getListener} = mockAddEventListener(); + const {getWindow} = mockGetWindow(); + + renderHook({onResult, selector: state => state.height}); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenNthCalledWith(1, expectedDimensions.height); + + // Act + act(() => { + const listener = getListener(); + const newWindow = {...expectedDimensions, height: newHeight}; + getWindow.mockReturnValue(newWindow); + listener({window: newWindow}); + }); + + // Assert + expect(onResult).toHaveBeenCalledTimes(2); + expect(onResult).toHaveBeenNthCalledWith(2, newHeight); + }); + + it('should return derived value based on state', () => { + // Arrange + const onResult = jest.fn(); + + // Act + renderHook({onResult, selector: state => state.width / state.height}); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith( + expectedDimensions.width / expectedDimensions.height, + ); + }); + + it('should not re-render if selected value has not changed', () => { + // Arrange + const onResult = jest.fn(); + const {getListener} = mockAddEventListener(); + const {getWindow} = mockGetWindow(); + + renderHook({onResult, selector: state => state.fontScale}); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expectedDimensions.fontScale); + + // Act + act(() => { + const listener = getListener(); + const newWindow = {...expectedDimensions, width: 400, height: 400}; + getWindow.mockReturnValue(newWindow); + listener({window: newWindow}); + }); + + // Assert + expect(onResult).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react-native/Libraries/Utilities/useWindowDimensions.js b/packages/react-native/Libraries/Utilities/useWindowDimensions.js index f0aebf07e676..a7cc054abed5 100644 --- a/packages/react-native/Libraries/Utilities/useWindowDimensions.js +++ b/packages/react-native/Libraries/Utilities/useWindowDimensions.js @@ -13,35 +13,58 @@ import { type DisplayMetrics, type DisplayMetricsAndroid, } from './NativeDeviceInfo'; -import {useEffect, useState} from 'react'; - -export default function useWindowDimensions(): - | DisplayMetrics - | DisplayMetricsAndroid { - const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); - useEffect(() => { - function handleChange({ - window, - }: { - window: DisplayMetrics | DisplayMetricsAndroid, - }) { - if ( - dimensions.width !== window.width || - dimensions.height !== window.height || - dimensions.scale !== window.scale || - dimensions.fontScale !== window.fontScale - ) { - setDimensions(window); - } +import {useCallback, useRef, useSyncExternalStore} from 'react'; + +type DisplayMetricsUnion = DisplayMetrics | DisplayMetricsAndroid; + +const defaultSelector = (state: DisplayMetricsUnion): DisplayMetricsUnion => + state; + +const hasWindowChanged = ( + prev: T, + next: T, +): boolean => { + // When dev called `useWindowDimensions()` without selector + if ( + typeof next === 'object' && + next != null && + typeof prev === 'object' && + prev != null + ) { + return ( + prev.width !== next.width || + prev.height !== next.height || + prev.scale !== next.scale || + prev.fontScale !== next.fontScale + ); + } + + // When dev called `useWindowDimensions(state => state.fontScale)` with a selector fn. + return !Object.is(prev, next); +}; + +const getSnapshot = () => Dimensions.get('window'); + +const subscribe = (callback: () => void) => { + const subscription = Dimensions.addEventListener('change', callback); + return () => subscription.remove(); +}; + +export default function useWindowDimensions( + // $FlowFixMe[incompatible-type] + selector: (state: DisplayMetricsUnion) => T = defaultSelector, +): T { + // $FlowFixMe[incompatible-type] + const prevRef = useRef(); + + const getSnapshotWithSelector = useCallback((): T => { + const prev = prevRef.current; + const next = selector(getSnapshot()); + if (hasWindowChanged(prev, next)) { + prevRef.current = next; } - 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({window: Dimensions.get('window')}); - return () => { - subscription.remove(); - }; - }, [dimensions]); - return dimensions; + return prevRef.current; + }, [selector]); + + return useSyncExternalStore(subscribe, getSnapshotWithSelector); } diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index abd52ddb4370..80d9139dc64a 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<<08dd369849273136812ea5edbda6e1df>> + * @generated SignedSource<<4beb82a8dc90a6e9173cb93f263d3a67>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -2027,6 +2027,7 @@ declare type DisplayMetricsAndroid = { scale: number width: number } +declare type DisplayMetricsUnion = DisplayMetrics | DisplayMetricsAndroid declare type DisplayModeType = symbol & { __DisplayModeType__: string } @@ -5681,7 +5682,9 @@ declare function useColorScheme(): ColorSchemeName | null declare function usePressability( config: null | PressabilityConfig | undefined, ): null | PressabilityEventHandlers -declare function useWindowDimensions(): DisplayMetrics | DisplayMetricsAndroid +declare function useWindowDimensions( + selector?: (state: DisplayMetricsUnion) => T, +): T declare type UTFSequence = typeof UTFSequence declare type Value = null | { horizontal: boolean @@ -6281,5 +6284,5 @@ export { useAnimatedValueXY, // c7ee2332 useColorScheme, // d585efdb usePressability, // b4e21b46 - useWindowDimensions, // bb4b683f + useWindowDimensions, // 00ecfbb5 } diff --git a/packages/react-native/types/__typetests__/index.tsx b/packages/react-native/types/__typetests__/index.tsx index e3c90772a81c..03f519ccf85b 100644 --- a/packages/react-native/types/__typetests__/index.tsx +++ b/packages/react-native/types/__typetests__/index.tsx @@ -168,6 +168,11 @@ function testDimensions() { function TextUseWindowDimensions() { const {width, height, scale, fontScale} = useWindowDimensions(); + const fontScale1: number = useWindowDimensions(state => state.fontScale); + // @ts-expect-error: Type number is not assignable to type string + const aspectRatio: string = useWindowDimensions( + state => state.width / state.height, + ); } BackHandler.addEventListener('hardwareBackPress', () => true).remove();