Skip to content

Commit b009b8b

Browse files
authored
perf: memoize ThemeContext provider value to decouple theme consumers from dimension changes (#7420)
1 parent 5a1e089 commit b009b8b

3 files changed

Lines changed: 92 additions & 11 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { act, fireEvent, render } from '@testing-library/react-native';
2+
import { useContext, useState } from 'react';
3+
import { TouchableOpacity } from 'react-native';
4+
5+
import ThemeContextProvider from './ThemeContextProvider';
6+
import { ThemeContext } from '../theme';
7+
import type { IThemePreference } from '../definitions/ITheme';
8+
9+
const defaultPrefs: IThemePreference = { currentTheme: 'light', darkLevel: 'dark' };
10+
const setTheme = jest.fn();
11+
12+
function ContextCapture({ onCapture }: { onCapture: (v: object) => void }) {
13+
const value = useContext(ThemeContext);
14+
onCapture(value);
15+
return null;
16+
}
17+
18+
// Parent holds its own counter state; ThemeContextProvider receives fixed props.
19+
// This forces ThemeContextProvider to re-render on parent state changes, exercising the
20+
// useMemo dep-check rather than React's same-props bailout shortcut.
21+
function ParentWithCounter({ onCapture }: { onCapture: (v: object) => void }) {
22+
const [count, setCount] = useState(0);
23+
return (
24+
<>
25+
<ThemeContextProvider theme='light' themePreferences={defaultPrefs} setTheme={setTheme}>
26+
<ContextCapture onCapture={onCapture} />
27+
</ThemeContextProvider>
28+
<TouchableOpacity testID='bump' onPress={() => setCount(c => c + 1)} accessibilityLabel={String(count)} />
29+
</>
30+
);
31+
}
32+
33+
test('value reference is stable across re-renders when theme/prefs unchanged', () => {
34+
const captured: object[] = [];
35+
const { getByTestId } = render(<ParentWithCounter onCapture={v => captured.push(v)} />);
36+
37+
act(() => fireEvent.press(getByTestId('bump')));
38+
act(() => fireEvent.press(getByTestId('bump')));
39+
40+
expect(captured.length).toBe(3);
41+
// All three captures must be the exact same object reference.
42+
expect(captured[1]).toBe(captured[0]);
43+
expect(captured[2]).toBe(captured[0]);
44+
});
45+
46+
test('value reference changes when theme prop changes', () => {
47+
const captured: object[] = [];
48+
const Wrapper = ({ theme }: { theme: 'light' | 'dark' }) => (
49+
<ThemeContextProvider theme={theme} themePreferences={defaultPrefs} setTheme={setTheme}>
50+
<ContextCapture onCapture={v => captured.push(v)} />
51+
</ThemeContextProvider>
52+
);
53+
54+
const { rerender } = render(<Wrapper theme='light' />);
55+
rerender(<Wrapper theme='dark' />);
56+
57+
expect(captured.length).toBe(2);
58+
expect(captured[1]).not.toBe(captured[0]);
59+
expect(captured[1]).toHaveProperty('theme', 'dark');
60+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useMemo, type ReactNode } from 'react';
2+
3+
import { type IThemePreference } from '../definitions/ITheme';
4+
import { colors } from '../lib/constants/colors';
5+
import { type IThemeContextProps, type TSupportedThemes, ThemeContext } from '../theme';
6+
7+
// Memoized value so unrelated App re-renders don't change context identity and re-render theme consumers.
8+
const ThemeContextProvider = ({
9+
theme,
10+
themePreferences,
11+
setTheme,
12+
children
13+
}: {
14+
theme: TSupportedThemes;
15+
themePreferences: IThemePreference;
16+
setTheme: IThemeContextProps['setTheme'];
17+
children: ReactNode;
18+
}) => {
19+
const value = useMemo<IThemeContextProps>(
20+
() => ({ theme, themePreferences, setTheme, colors: colors[theme] }),
21+
[theme, themePreferences, setTheme]
22+
);
23+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
24+
};
25+
26+
export default ThemeContextProvider;

app/index.tsx

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import { deepLinkingOpen } from './actions/deepLinking';
1313
import { ActionSheetProvider } from './containers/ActionSheet';
1414
import InAppNotification from './containers/InAppNotification';
1515
import Loading from './containers/Loading';
16+
import StatusBar from './containers/StatusBar';
17+
import ThemeContextProvider from './containers/ThemeContextProvider';
1618
import Toast from './containers/Toast';
1719
import TwoFactor from './containers/TwoFactor';
1820
import { type IThemePreference } from './definitions/ITheme';
19-
import { colors, themes } from './lib/constants/colors';
21+
import { themes } from './lib/constants/colors';
2022
import { getAllowAnalyticsEvents, getAllowCrashReport } from './lib/methods/crashReport';
2123
import { toggleAnalyticsEventsReport, toggleCrashErrorsReport } from './lib/methods/helpers/log';
2224
import parseQuery from './lib/methods/helpers/parseQuery';
@@ -37,10 +39,9 @@ import {
3739
} from './lib/services/voip/MediaCallEvents';
3840
import store from './lib/store';
3941
import { initStore } from './lib/store/auxStore';
40-
import { type TSupportedThemes, ThemeContext } from './theme';
42+
import { type TSupportedThemes } from './theme';
4143
import ChangePasscodeView from './views/ChangePasscodeView';
4244
import ScreenLockedView from './views/ScreenLockedView';
43-
import StatusBar from './containers/StatusBar';
4445

4546
enableScreens();
4647
initStore(store);
@@ -183,13 +184,7 @@ export default class Root extends Component<{}, IState> {
183184
return (
184185
<SafeAreaProvider style={{ backgroundColor: themes[this.state.theme].surfaceRoom }}>
185186
<Provider store={store}>
186-
<ThemeContext.Provider
187-
value={{
188-
theme,
189-
themePreferences,
190-
setTheme: this.setTheme,
191-
colors: colors[theme]
192-
}}>
187+
<ThemeContextProvider theme={theme} themePreferences={themePreferences} setTheme={this.setTheme}>
193188
<ResponsiveLayoutProvider>
194189
<GestureHandlerRootView>
195190
<KeyboardProvider>
@@ -206,7 +201,7 @@ export default class Root extends Component<{}, IState> {
206201
</KeyboardProvider>
207202
</GestureHandlerRootView>
208203
</ResponsiveLayoutProvider>
209-
</ThemeContext.Provider>
204+
</ThemeContextProvider>
210205
</Provider>
211206
</SafeAreaProvider>
212207
);

0 commit comments

Comments
 (0)