Skip to content

Commit 59cc1e5

Browse files
committed
perf: Allow to pass a selector to useWindowDimensions(state => state.fontScale) to avoid unnecessary re-renders
1 parent d8f7183 commit 59cc1e5

5 files changed

Lines changed: 297 additions & 33 deletions

File tree

packages/react-native/Libraries/Utilities/Dimensions.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,6 @@ export interface Dimensions {
7575
}
7676

7777
export function useWindowDimensions(): ScaledSize;
78+
export function useWindowDimensions<T>(selector: (state: ScaledSize) => T): T;
7879

7980
export const Dimensions: Dimensions;
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import View from '../../Components/View/View';
12+
import Dimensions from '../Dimensions';
13+
import {
14+
type DisplayMetrics,
15+
type DisplayMetricsAndroid,
16+
} from '../NativeDeviceInfo';
17+
import useWindowDimensions from '../useWindowDimensions';
18+
import {useEffect} from 'react';
19+
import {act, create} from 'react-test-renderer';
20+
21+
type State = DisplayMetrics | DisplayMetricsAndroid;
22+
type TestProps = {
23+
selector?: (state: State) => number,
24+
onResult?: (result: number | State) => void,
25+
testID?: string,
26+
};
27+
function TestView({selector, onResult}: TestProps) {
28+
const result = useWindowDimensions(selector);
29+
useEffect(() => {
30+
onResult?.(result);
31+
}, [onResult, result]);
32+
return <View />;
33+
}
34+
35+
const defaultWindow = {fontScale: 2, height: 1334, scale: 2, width: 750};
36+
37+
describe('useWindowDimensions', () => {
38+
const expectedDimensions = Dimensions.get('window');
39+
let cleanupFns = [];
40+
41+
// Auto cleanup
42+
afterEach(() => {
43+
cleanupFns.forEach(fn => fn());
44+
cleanupFns = [];
45+
});
46+
47+
const renderHook = (props?: TestProps) => {
48+
let root;
49+
const defaultProps: TestProps = {onResult: jest.fn(), selector: undefined};
50+
// Mount
51+
act(() => {
52+
root = create(<TestView {...defaultProps} {...props} />);
53+
});
54+
55+
const rerender = (newProps: TestProps) => {
56+
act(() => {
57+
root.update(<TestView {...defaultProps} {...props} {...newProps} />);
58+
});
59+
};
60+
const unmount = () => {
61+
act(() => {
62+
root.unmount();
63+
});
64+
};
65+
cleanupFns.push(unmount); // auto-cleanup
66+
return {unmount, rerender};
67+
};
68+
69+
const mockGetWindow = () => {
70+
const spy = jest.spyOn(Dimensions, 'get');
71+
cleanupFns.push(() => spy.mockRestore()); // auto-cleanup
72+
return {
73+
getWindow: spy,
74+
};
75+
};
76+
const mockAddEventListener = () => {
77+
const sub = {remove: jest.fn()};
78+
const spy = jest
79+
.spyOn(Dimensions, 'addEventListener')
80+
.mockImplementation(() => sub);
81+
cleanupFns.push(() => spy.mockRestore()); // auto-cleanup
82+
return {
83+
addListener: spy,
84+
removeListener: sub.remove,
85+
// $FlowFixMe[unclear-type]
86+
getListener: (): Function => spy.mock.calls.at(-1)?.at(1), // `-1` - last call, `1` - second argument
87+
};
88+
};
89+
90+
it('should cleanup a listener on a component unmount', () => {
91+
// Arrange
92+
const {addListener, removeListener} = mockAddEventListener();
93+
94+
const {unmount} = renderHook();
95+
96+
expect(addListener).toHaveBeenCalledTimes(1);
97+
expect(addListener).toHaveBeenCalledWith('change', expect.any(Function));
98+
99+
// Act
100+
unmount();
101+
102+
// Assert
103+
expect(removeListener).toHaveBeenCalledTimes(1);
104+
expect(removeListener).toHaveBeenCalledWith();
105+
});
106+
107+
it('should return the current window dimensions on mount', () => {
108+
// Arrange
109+
const onResult = jest.fn();
110+
111+
// Act
112+
renderHook({onResult});
113+
114+
// Assert
115+
expect(onResult).toHaveBeenCalledTimes(1);
116+
expect(onResult).toHaveBeenCalledWith(expectedDimensions);
117+
expect(expectedDimensions).toStrictEqual(defaultWindow);
118+
});
119+
120+
it('should return the same object on re-render', () => {
121+
// Arrange
122+
const onResult = jest.fn();
123+
124+
const {rerender} = renderHook({onResult});
125+
126+
expect(onResult).toHaveBeenCalledTimes(1);
127+
expect(onResult).toHaveBeenCalledWith(expectedDimensions);
128+
expect(expectedDimensions).toStrictEqual(defaultWindow);
129+
130+
// Act
131+
rerender({testID: 'test-123'});
132+
133+
// Assert
134+
expect(onResult).toHaveBeenCalledTimes(1);
135+
});
136+
137+
it('should not re-render when screen dimension has changed but window is the same', () => {
138+
// Arrange
139+
const {getListener} = mockAddEventListener();
140+
const onResult = jest.fn();
141+
renderHook({onResult});
142+
143+
expect(onResult).toHaveBeenCalledTimes(1);
144+
expect(onResult).toHaveBeenCalledWith(expectedDimensions);
145+
expect(expectedDimensions).toStrictEqual(defaultWindow);
146+
147+
// Act
148+
const listener = getListener();
149+
act(() => {
150+
listener({
151+
window: {...expectedDimensions},
152+
screen: {...expectedDimensions, height: 1000},
153+
});
154+
});
155+
156+
// Assert
157+
expect(onResult).toHaveBeenCalledTimes(1);
158+
});
159+
160+
describe('selector argument', () => {
161+
it('should return partial of state', () => {
162+
const onResult = jest.fn();
163+
164+
renderHook({onResult, selector: state => state.height});
165+
166+
// Assert
167+
expect(onResult).toHaveBeenCalledTimes(1);
168+
expect(onResult).toHaveBeenCalledWith(expectedDimensions.height);
169+
});
170+
171+
it('should re-render if selected value has changed', () => {
172+
// Arrange
173+
const newHeight = 666;
174+
const onResult = jest.fn();
175+
const {getListener} = mockAddEventListener();
176+
const {getWindow} = mockGetWindow();
177+
178+
renderHook({onResult, selector: state => state.height});
179+
180+
expect(onResult).toHaveBeenCalledTimes(1);
181+
expect(onResult).toHaveBeenNthCalledWith(1, expectedDimensions.height);
182+
183+
// Act
184+
act(() => {
185+
const listener = getListener();
186+
const newWindow = {...expectedDimensions, height: newHeight};
187+
getWindow.mockReturnValue(newWindow);
188+
listener({window: newWindow});
189+
});
190+
191+
// Assert
192+
expect(onResult).toHaveBeenCalledTimes(2);
193+
expect(onResult).toHaveBeenNthCalledWith(2, newHeight);
194+
});
195+
196+
it('should return derived value based on state', () => {
197+
// Arrange
198+
const onResult = jest.fn();
199+
200+
// Act
201+
renderHook({onResult, selector: state => state.width / state.height});
202+
203+
// Assert
204+
expect(onResult).toHaveBeenCalledTimes(1);
205+
expect(onResult).toHaveBeenCalledWith(
206+
expectedDimensions.width / expectedDimensions.height,
207+
);
208+
});
209+
210+
it('should not re-render if selected value has not changed', () => {
211+
// Arrange
212+
const onResult = jest.fn();
213+
const {getListener} = mockAddEventListener();
214+
const {getWindow} = mockGetWindow();
215+
216+
renderHook({onResult, selector: state => state.fontScale});
217+
expect(onResult).toHaveBeenCalledTimes(1);
218+
expect(onResult).toHaveBeenCalledWith(expectedDimensions.fontScale);
219+
220+
// Act
221+
act(() => {
222+
const listener = getListener();
223+
const newWindow = {...expectedDimensions, width: 400, height: 400};
224+
getWindow.mockReturnValue(newWindow);
225+
listener({window: newWindow});
226+
});
227+
228+
// Assert
229+
expect(onResult).toHaveBeenCalledTimes(1);
230+
});
231+
});
232+
});

packages/react-native/Libraries/Utilities/useWindowDimensions.js

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,35 +13,58 @@ import {
1313
type DisplayMetrics,
1414
type DisplayMetricsAndroid,
1515
} from './NativeDeviceInfo';
16-
import {useEffect, useState} from 'react';
17-
18-
export default function useWindowDimensions():
19-
| DisplayMetrics
20-
| DisplayMetricsAndroid {
21-
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
22-
useEffect(() => {
23-
function handleChange({
24-
window,
25-
}: {
26-
window: DisplayMetrics | DisplayMetricsAndroid,
27-
}) {
28-
if (
29-
dimensions.width !== window.width ||
30-
dimensions.height !== window.height ||
31-
dimensions.scale !== window.scale ||
32-
dimensions.fontScale !== window.fontScale
33-
) {
34-
setDimensions(window);
35-
}
16+
import {useCallback, useRef, useSyncExternalStore} from 'react';
17+
18+
type DisplayMetricsUnion = DisplayMetrics | DisplayMetricsAndroid;
19+
20+
const defaultSelector = (state: DisplayMetricsUnion): DisplayMetricsUnion =>
21+
state;
22+
23+
const hasWindowChanged = <T = DisplayMetricsUnion>(
24+
prev: T,
25+
next: T,
26+
): boolean => {
27+
// When dev called `useWindowDimensions()` without selector
28+
if (
29+
typeof next === 'object' &&
30+
next != null &&
31+
typeof prev === 'object' &&
32+
prev != null
33+
) {
34+
return (
35+
prev.width !== next.width ||
36+
prev.height !== next.height ||
37+
prev.scale !== next.scale ||
38+
prev.fontScale !== next.fontScale
39+
);
40+
}
41+
42+
// When dev called `useWindowDimensions(state => state.fontScale)` with a selector fn.
43+
return !Object.is(prev, next);
44+
};
45+
46+
const getSnapshot = () => Dimensions.get('window');
47+
48+
const subscribe = (callback: () => void) => {
49+
const subscription = Dimensions.addEventListener('change', callback);
50+
return () => subscription.remove();
51+
};
52+
53+
export default function useWindowDimensions<T = DisplayMetricsUnion>(
54+
// $FlowFixMe[incompatible-type]
55+
selector: (state: DisplayMetricsUnion) => T = defaultSelector,
56+
): T {
57+
// $FlowFixMe[incompatible-type]
58+
const prevRef = useRef<T>();
59+
60+
const getSnapshotWithSelector = useCallback((): T => {
61+
const prev = prevRef.current;
62+
const next = selector(getSnapshot());
63+
if (hasWindowChanged<T>(prev, next)) {
64+
prevRef.current = next;
3665
}
37-
const subscription = Dimensions.addEventListener('change', handleChange);
38-
// We might have missed an update between calling `get` in render and
39-
// `addEventListener` in this handler, so we set it here. If there was
40-
// no change, React will filter out this update as a no-op.
41-
handleChange({window: Dimensions.get('window')});
42-
return () => {
43-
subscription.remove();
44-
};
45-
}, [dimensions]);
46-
return dimensions;
66+
return prevRef.current;
67+
}, [selector]);
68+
69+
return useSyncExternalStore(subscribe, getSnapshotWithSelector);
4770
}

packages/react-native/ReactNativeApi.d.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<bf19d4967e7244131a0ed9b4431d167b>>
7+
* @generated SignedSource<<7d9eeaf16b1a97d0822637e01c2c2cc0>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -1951,6 +1951,7 @@ declare type DisplayMetricsAndroid = {
19511951
scale: number
19521952
width: number
19531953
}
1954+
declare type DisplayMetricsUnion = DisplayMetrics | DisplayMetricsAndroid
19541955
declare type DisplayModeType = symbol & {
19551956
__DisplayModeType__: string
19561957
}
@@ -5528,7 +5529,9 @@ declare function useColorScheme(): ColorSchemeName | null
55285529
declare function usePressability(
55295530
config: null | PressabilityConfig | undefined,
55305531
): null | PressabilityEventHandlers
5531-
declare function useWindowDimensions(): DisplayMetrics | DisplayMetricsAndroid
5532+
declare function useWindowDimensions<T = DisplayMetricsUnion>(
5533+
selector?: (state: DisplayMetricsUnion) => T,
5534+
): T
55325535
declare type UTFSequence = typeof UTFSequence
55335536
declare type Value = null | {
55345537
horizontal: boolean
@@ -6149,5 +6152,5 @@ export {
61496152
useAnimatedValueXY, // c7ee2332
61506153
useColorScheme, // d585efdb
61516154
usePressability, // 095343b5
6152-
useWindowDimensions, // bb4b683f
6155+
useWindowDimensions, // 00ecfbb5
61536156
}

packages/react-native/types/__typetests__/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ function testDimensions() {
168168

169169
function TextUseWindowDimensions() {
170170
const {width, height, scale, fontScale} = useWindowDimensions();
171+
const fontScale1: number = useWindowDimensions(state => state.fontScale);
172+
// @ts-expect-error: Type number is not assignable to type string
173+
const aspectRatio: string = useWindowDimensions(
174+
state => state.width / state.height,
175+
);
171176
}
172177

173178
BackHandler.addEventListener('hardwareBackPress', () => true).remove();

0 commit comments

Comments
 (0)