Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-native/Libraries/Utilities/Dimensions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,6 @@ export interface Dimensions {
}

export function useWindowDimensions(): ScaledSize;
export function useWindowDimensions<T>(selector: (state: ScaledSize) => T): T;

export const Dimensions: Dimensions;
Original file line number Diff line number Diff line change
@@ -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 <View />;
}

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(<TestView {...defaultProps} {...props} />);
});

const rerender = (newProps: TestProps) => {
act(() => {
root.update(<TestView {...defaultProps} {...props} {...newProps} />);
});
};
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);
});
});
});
83 changes: 53 additions & 30 deletions packages/react-native/Libraries/Utilities/useWindowDimensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T = DisplayMetricsUnion>(
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<T = DisplayMetricsUnion>(
// $FlowFixMe[incompatible-type]
selector: (state: DisplayMetricsUnion) => T = defaultSelector,
): T {
// $FlowFixMe[incompatible-type]
const prevRef = useRef<T>();

const getSnapshotWithSelector = useCallback((): T => {
const prev = prevRef.current;
const next = selector(getSnapshot());
if (hasWindowChanged<T>(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);
}
9 changes: 6 additions & 3 deletions packages/react-native/ReactNativeApi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -2027,6 +2027,7 @@ declare type DisplayMetricsAndroid = {
scale: number
width: number
}
declare type DisplayMetricsUnion = DisplayMetrics | DisplayMetricsAndroid
declare type DisplayModeType = symbol & {
__DisplayModeType__: string
}
Expand Down Expand Up @@ -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<T = DisplayMetricsUnion>(
selector?: (state: DisplayMetricsUnion) => T,
): T
declare type UTFSequence = typeof UTFSequence
declare type Value = null | {
horizontal: boolean
Expand Down Expand Up @@ -6281,5 +6284,5 @@ export {
useAnimatedValueXY, // c7ee2332
useColorScheme, // d585efdb
usePressability, // b4e21b46
useWindowDimensions, // bb4b683f
useWindowDimensions, // 00ecfbb5
}
5 changes: 5 additions & 0 deletions packages/react-native/types/__typetests__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading