Skip to content

Commit 541c42d

Browse files
⚡ Optimize SettingsSwitch re-renders (#216)
Applied React.memo to SettingsSwitch component with a custom comparison function to prevent unnecessary re-renders. The custom comparison ignores the `onValueChange` prop, which is unstable due to inline function definition in the parent component (App.js), while checking `label` and `value` for equality. Added a performance regression test `__tests__/SettingsSwitchPerf.test.js` which verifies that SettingsSwitch does not re-render when unrelated state changes in the App. Benchmark results: - Baseline: 2 re-renders of SettingsSwitch on unrelated App state update. - Optimized: 0 re-renders. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: xRahul <1639945+xRahul@users.noreply.github.com>
1 parent 7b0434f commit 541c42d

2 files changed

Lines changed: 163 additions & 1 deletion

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from 'react';
2+
import App from '../src/App';
3+
import renderer, {act} from 'react-test-renderer';
4+
import AsyncStorage from '@react-native-community/async-storage';
5+
6+
// Mock dependencies
7+
jest.mock('react-native-background-timer', () => ({
8+
stopBackgroundTimer: jest.fn(),
9+
runBackgroundTimer: jest.fn(),
10+
}));
11+
12+
jest.mock('react-native-push-notification', () => ({
13+
configure: jest.fn(),
14+
localNotification: jest.fn(),
15+
}));
16+
17+
jest.mock('@react-native-community/async-storage', () => ({
18+
setItem: jest.fn(() => Promise.resolve()),
19+
multiSet: jest.fn(() => Promise.resolve()),
20+
getItem: jest.fn(() => Promise.resolve(null)),
21+
getAllKeys: jest.fn(() => Promise.resolve([])),
22+
multiGet: jest.fn(() => Promise.resolve([])),
23+
removeItem: jest.fn(() => Promise.resolve()),
24+
}));
25+
26+
jest.mock('react-native-webview', () => {
27+
return {
28+
WebView: () => null,
29+
};
30+
});
31+
32+
jest.mock('../src/services/BackgroundService', () => ({
33+
checkUrlForText: jest.fn(),
34+
background_task: jest.fn(),
35+
}));
36+
37+
// Mock SettingsSwitch to track renders
38+
// We need to do this slightly differently to spy on the component itself
39+
// But since we want to test the *real* SettingsSwitch, we shouldn't mock it entirely.
40+
// Instead, we can mock the 'Switch' component from react-native which SettingsSwitch renders.
41+
42+
const mockSwitchRender = jest.fn();
43+
44+
jest.mock('react-native', () => {
45+
const React = require('react');
46+
const View = props => React.createElement('View', props, props.children);
47+
const Text = props => React.createElement('Text', props, props.children);
48+
const ScrollView = props =>
49+
React.createElement('ScrollView', props, props.children);
50+
const TextInput = React.forwardRef((props, ref) =>
51+
React.createElement('TextInput', {
52+
...props,
53+
ref,
54+
onChangeText: text => {
55+
if (props.onChangeText) {
56+
props.onChangeText(text);
57+
}
58+
},
59+
}),
60+
);
61+
62+
// We spy on Switch render
63+
const Switch = props => {
64+
mockSwitchRender(props);
65+
return React.createElement('Switch', props);
66+
};
67+
68+
const Button = props => React.createElement('Button', props);
69+
const ActivityIndicator = props =>
70+
React.createElement('ActivityIndicator', props);
71+
72+
const Picker = props => React.createElement('Picker', props, props.children);
73+
Picker.Item = props => React.createElement('Picker.Item', props);
74+
75+
const PushNotificationIOS = {
76+
addEventListener: jest.fn(),
77+
removeEventListener: jest.fn(),
78+
requestPermissions: jest.fn(() => Promise.resolve({})),
79+
checkPermissions: jest.fn(),
80+
FetchResult: {
81+
NoData: 'NoData',
82+
NewData: 'NewData',
83+
Failed: 'Failed',
84+
},
85+
};
86+
87+
return {
88+
Platform: {
89+
OS: 'ios',
90+
select: obj => obj.ios,
91+
},
92+
View,
93+
Text,
94+
ScrollView,
95+
TextInput,
96+
Switch,
97+
Button,
98+
ActivityIndicator,
99+
Picker,
100+
PushNotificationIOS,
101+
StyleSheet: {
102+
create: obj => obj,
103+
flatten: obj => obj,
104+
},
105+
};
106+
});
107+
108+
it('prevents unnecessary re-renders of SettingsSwitch', async () => {
109+
mockSwitchRender.mockClear();
110+
111+
let component;
112+
await act(async () => {
113+
component = renderer.create(<App />);
114+
});
115+
116+
// Initial render: 2 SettingsSwitch components -> 2 Switch renders
117+
// Note: Initial render might happen more than once due to useEffect/AsyncStorage load
118+
// We should reset the counter after initial load is stable.
119+
120+
// Wait for useEffect to finish (loadState)
121+
// The mock of AsyncStorage returns promises, we need to wait for them.
122+
// act handles this if we await properly.
123+
124+
const initialRenderCount = mockSwitchRender.mock.calls.length;
125+
126+
// Reset count to measure update impact
127+
mockSwitchRender.mockClear();
128+
129+
// Find the UrlInput and change text
130+
// UrlInput renders a TextInput. We need to find it.
131+
const root = component.root;
132+
// UrlInput component renders a TextInput.
133+
// We can find TextInput by type or props.
134+
135+
// We need to trigger a state change in App.js that is NOT related to SettingsSwitch.
136+
// changing 'url' state via UrlInput seems perfect.
137+
138+
// But wait, UrlInput passes 'setUrl' which updates 'url' state in App.
139+
// Let's find the TextInput inside UrlInput.
140+
141+
// The structure is App -> UrlInput -> View -> TextInput
142+
const textInputs = root.findAllByType('TextInput');
143+
// First one should be UrlInput's input (based on App.js order)
144+
const urlInput = textInputs[0];
145+
146+
await act(async () => {
147+
// Trigger update
148+
urlInput.props.onChangeText('https://new-url.com');
149+
});
150+
151+
const afterUpdateRenderCount = mockSwitchRender.mock.calls.length;
152+
153+
// If optimized, SettingsSwitch should NOT re-render, so Switch should NOT re-render.
154+
// Count should be 0.
155+
expect(afterUpdateRenderCount).toBe(0);
156+
});

src/components/SettingsSwitch.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,10 @@ const SettingsSwitch = ({label, value, onValueChange}) => {
1313
);
1414
};
1515

16-
export default SettingsSwitch;
16+
const arePropsEqual = (prevProps, nextProps) => {
17+
return (
18+
prevProps.label === nextProps.label && prevProps.value === nextProps.value
19+
);
20+
};
21+
22+
export default React.memo(SettingsSwitch, arePropsEqual);

0 commit comments

Comments
 (0)