Skip to content

Commit 7d60920

Browse files
committed
Convert state providers to use use-context-selector
This user-land library improves performance by removing unnecessary re-renders to components which use global contexts. By selecting the state that the components use from the contexts, they'll limit their re-rendering to only when that state changes. See reactjs/rfcs#119 for more relevant information about the performance issues with the context API.
1 parent dbf3632 commit 7d60920

12 files changed

+178
-104
lines changed

jestSetup.js

+27
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,30 @@ jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock
233233
for (const log in global.console) {
234234
global.console[log] = jest.fn()
235235
}
236+
237+
jest.mock('use-context-selector', () => {
238+
const contextValues = new Map()
239+
return {
240+
createContext: defaultValue => {
241+
// Create new provider
242+
const Provider = (props, context) => {
243+
contextValues.set(Provider, props.value)
244+
return props.children
245+
}
246+
// Get the value for the provider:
247+
const currentValue = contextValues.get(Provider)
248+
// Set it's default value:
249+
contextValues.set(Provider, currentValue ?? defaultValue)
250+
// Return provider
251+
return {
252+
Provider: Provider,
253+
displayName: 'test'
254+
}
255+
},
256+
useContextSelector: (context, selector) => {
257+
const currentValue = contextValues.get(context.Provider)
258+
const selected = selector(currentValue)
259+
return selected
260+
}
261+
}
262+
})

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
"posthog-react-native": "^2.8.1",
156156
"prompts": "^2.4.2",
157157
"qrcode-generator": "^1.4.4",
158-
"react": "18.2.0",
158+
"react": "^18.2.0",
159159
"react-native": "0.71.15",
160160
"react-native-airship": "^0.2.12",
161161
"react-native-bootsplash": "^4.7.4",
@@ -206,10 +206,12 @@
206206
"redux-thunk": "^2.3.0",
207207
"rn-id-blurview": "^1.2.1",
208208
"rn-qr-generator": "^1.3.1",
209+
"scheduler": "^0.23.0",
209210
"sha.js": "^2.4.11",
210211
"sprintf-js": "^1.1.1",
211212
"url": "^0.11.0",
212213
"url-parse": "^1.5.2",
214+
"use-context-selector": "^1.4.1",
213215
"yaob": "^0.3.12",
214216
"yavent": "^0.1.3"
215217
},

src/components/common/SceneWrapper.tsx

+109-80
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function SceneWrapper(props: SceneWrapperProps): JSX.Element {
120120
const activeUsername = useSelector(state => state.core.account.username)
121121
const isLightAccount = accountId != null && activeUsername == null
122122

123-
const { footerHeight = 0 } = useSceneFooterState()
123+
const footerHeight = useSceneFooterState(({ footerHeight = 0 }) => footerHeight)
124124

125125
const navigation = useNavigation<NavigationBase>()
126126
const theme = useTheme()
@@ -138,88 +138,117 @@ export function SceneWrapper(props: SceneWrapperProps): JSX.Element {
138138
[frame.height, frame.width]
139139
)
140140

141-
const notificationHeight = theme.rem(4)
142-
const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)
143-
144141
// If the scene has scroll, this will be required for tabs and/or header animation
145142
const handleScroll = useSceneScrollHandler(scroll && (hasTabs || hasHeader))
146143

147-
const { renderFooter } = useSceneFooterRenderState()
148-
149-
const renderScene = (keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
150-
// If function children, the caller handles the insets and overscroll
151-
const isFuncChildren = typeof children === 'function'
152-
153-
// Derive the keyboard height by getting the difference between screen height
154-
// and trackerValue. This value should be from zero to keyboard height
155-
// depending on the open state of the keyboard
156-
const keyboardHeight = frame.height - trackerValue
157-
const isKeyboardOpen = avoidKeyboard && keyboardHeight !== 0
158-
159-
// Calculate app insets considering the app's header, tab-bar,
160-
// notification area, etc:
161-
const maybeHeaderHeight = hasHeader ? headerBarHeight : 0
162-
const maybeNotificationHeight = isLightAccount ? notificationHeight : 0
163-
const maybeTabBarHeight = hasTabs ? MAX_TAB_BAR_HEIGHT : 0
164-
const maybeInsetBottom = !hasTabs && !isKeyboardOpen ? safeAreaInsets.bottom : 0
165-
const insets: EdgeInsets = {
166-
top: safeAreaInsets.top + maybeHeaderHeight,
167-
right: safeAreaInsets.right,
168-
bottom: maybeInsetBottom + maybeNotificationHeight + maybeTabBarHeight + footerHeight,
169-
left: safeAreaInsets.left
170-
}
171-
172-
// This is a convenient styles object which may be applied as
173-
// contentContainerStyles for child scroll components. It will also be
174-
// used for the ScrollView component internal to the SceneWrapper.
175-
const insetStyle: InsetStyle = {
176-
paddingTop: insets.top,
177-
paddingRight: insets.right,
178-
paddingBottom: insets.bottom,
179-
paddingLeft: insets.left
180-
}
181-
182-
// This is a convenient styles object which may be applied to scene container
183-
// components to offset the inset styles applied to the SceneWrapper.
184-
const undoInsetStyle: UndoInsetStyle = {
185-
flex: 1,
186-
marginTop: -insets.top,
187-
marginRight: -insets.right,
188-
marginBottom: -insets.bottom,
189-
marginLeft: -insets.left
190-
}
191-
192-
const info: SceneWrapperInfo = { insets, insetStyle, undoInsetStyle, hasTabs, isKeyboardOpen }
193-
194-
return (
195-
<>
196-
<MaybeAnimatedView when={avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { maxHeight: keyboardAnimation, padding }]}>
197-
<DotsBackground
198-
accentColors={accentColors}
199-
overrideDots={overrideDots}
200-
backgroundGradientColors={backgroundGradientColors}
201-
backgroundGradientStart={backgroundGradientStart}
202-
backgroundGradientEnd={backgroundGradientEnd}
203-
/>
204-
<MaybeAnimatedScrollView
205-
when={scroll && !avoidKeyboard}
206-
style={[layoutStyle, { padding }]}
207-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
208-
contentContainerStyle={insetStyle}
209-
onScroll={hasTabs || hasHeader ? handleScroll : () => {}}
210-
// Fixes middle-floating scrollbar issue
211-
scrollIndicatorInsets={{ right: 1 }}
212-
>
213-
<MaybeView when={!scroll && !avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { padding }]}>
214-
{isFuncChildren ? children(info) : children}
215-
</MaybeView>
216-
</MaybeAnimatedScrollView>
217-
{renderFooter != null && !hasTabs ? <SceneFooter>{renderFooter(info)}</SceneFooter> : null}
218-
</MaybeAnimatedView>
219-
{hasNotifications ? <NotificationView hasTabs={hasTabs} navigation={navigation} /> : null}
220-
</>
221-
)
222-
}
144+
const renderFooter = useSceneFooterRenderState(({ renderFooter }) => renderFooter)
145+
146+
const renderScene = React.useCallback(
147+
(keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
148+
const notificationHeight = theme.rem(4)
149+
const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)
150+
151+
// If function children, the caller handles the insets and overscroll
152+
const isFuncChildren = typeof children === 'function'
153+
154+
// Derive the keyboard height by getting the difference between screen height
155+
// and trackerValue. This value should be from zero to keyboard height
156+
// depending on the open state of the keyboard
157+
const keyboardHeight = frame.height - trackerValue
158+
const isKeyboardOpen = avoidKeyboard && keyboardHeight !== 0
159+
160+
// Calculate app insets considering the app's header, tab-bar,
161+
// notification area, etc:
162+
const maybeHeaderHeight = hasHeader ? headerBarHeight : 0
163+
const maybeNotificationHeight = isLightAccount ? notificationHeight : 0
164+
const maybeTabBarHeight = hasTabs ? MAX_TAB_BAR_HEIGHT : 0
165+
const maybeInsetBottom = !hasTabs && !isKeyboardOpen ? safeAreaInsets.bottom : 0
166+
const insets: EdgeInsets = {
167+
top: safeAreaInsets.top + maybeHeaderHeight,
168+
right: safeAreaInsets.right,
169+
bottom: maybeInsetBottom + maybeNotificationHeight + maybeTabBarHeight + footerHeight,
170+
left: safeAreaInsets.left
171+
}
172+
173+
// This is a convenient styles object which may be applied as
174+
// contentContainerStyles for child scroll components. It will also be
175+
// used for the ScrollView component internal to the SceneWrapper.
176+
const insetStyle: InsetStyle = {
177+
paddingTop: insets.top,
178+
paddingRight: insets.right,
179+
paddingBottom: insets.bottom,
180+
paddingLeft: insets.left
181+
}
182+
183+
// This is a convenient styles object which may be applied to scene container
184+
// components to offset the inset styles applied to the SceneWrapper.
185+
const undoInsetStyle: UndoInsetStyle = {
186+
flex: 1,
187+
marginTop: -insets.top,
188+
marginRight: -insets.right,
189+
marginBottom: -insets.bottom,
190+
marginLeft: -insets.left
191+
}
192+
193+
const info: SceneWrapperInfo = { insets, insetStyle, undoInsetStyle, hasTabs, isKeyboardOpen }
194+
195+
return (
196+
<>
197+
<MaybeAnimatedView when={avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { maxHeight: keyboardAnimation, padding }]}>
198+
<DotsBackground
199+
accentColors={accentColors}
200+
overrideDots={overrideDots}
201+
backgroundGradientColors={backgroundGradientColors}
202+
backgroundGradientStart={backgroundGradientStart}
203+
backgroundGradientEnd={backgroundGradientEnd}
204+
/>
205+
<MaybeAnimatedScrollView
206+
when={scroll && !avoidKeyboard}
207+
style={[layoutStyle, { padding }]}
208+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
209+
contentContainerStyle={insetStyle}
210+
onScroll={hasTabs || hasHeader ? handleScroll : () => {}}
211+
// Fixes middle-floating scrollbar issue
212+
scrollIndicatorInsets={{ right: 1 }}
213+
>
214+
<MaybeView when={!scroll && !avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { padding }]}>
215+
{isFuncChildren ? children(info) : children}
216+
</MaybeView>
217+
</MaybeAnimatedScrollView>
218+
{renderFooter != null && !hasTabs ? <SceneFooter>{renderFooter(info)}</SceneFooter> : null}
219+
</MaybeAnimatedView>
220+
{hasNotifications ? <NotificationView hasTabs={hasTabs} navigation={navigation} /> : null}
221+
</>
222+
)
223+
},
224+
[
225+
accentColors,
226+
avoidKeyboard,
227+
backgroundGradientColors,
228+
backgroundGradientEnd,
229+
backgroundGradientStart,
230+
children,
231+
footerHeight,
232+
frame,
233+
handleScroll,
234+
hasHeader,
235+
hasNotifications,
236+
hasTabs,
237+
isLightAccount,
238+
keyboardShouldPersistTaps,
239+
layoutStyle,
240+
navigation,
241+
overrideDots,
242+
padding,
243+
renderFooter,
244+
safeAreaInsets.bottom,
245+
safeAreaInsets.left,
246+
safeAreaInsets.right,
247+
safeAreaInsets.top,
248+
scroll,
249+
theme
250+
]
251+
)
223252

224253
// These represent the distance from the top of the screen to the top of
225254
// the keyboard depending if the keyboard is down or up.

src/components/navigation/HeaderBackground.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { BlurBackground } from '../ui4/BlurBackground'
1313
export const HeaderBackground = (props: any) => {
1414
const theme = useTheme()
1515

16-
const { scrollState } = useSceneScrollContext()
16+
const scrollState = useSceneScrollContext(state => state.scrollState)
1717

1818
return (
1919
<HeaderBackgroundContainerView scrollY={scrollState.scrollY}>

src/components/notification/NotificationView.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const NotificationViewComponent = (props: Props) => {
3333
const isBackupWarningShown = account.id != null && account.username == null
3434

3535
const { bottom: insetBottom } = useSafeAreaInsets()
36-
const { footerOpenRatio, footerHeight = 0 } = useSceneFooterState()
36+
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
37+
const footerHeight = useSceneFooterState(({ footerHeight = 0 }) => footerHeight)
3738

3839
const [autoDetectTokenCards, setAutoDetectTokenCards] = React.useState<React.JSX.Element[]>([])
3940

src/components/themed/MenuTabs.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ export const MenuTabs = (props: BottomTabBarProps) => {
7070

7171
const { bottom: insetBottom } = useSafeAreaInsets()
7272

73-
const { footerOpenRatio, resetFooterRatio } = useSceneFooterState()
74-
const { renderFooter } = useSceneFooterRenderState()
73+
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
74+
const resetFooterRatio = useSceneFooterState(({ resetFooterRatio }) => resetFooterRatio)
75+
const renderFooter = useSceneFooterRenderState(({ renderFooter }) => renderFooter)
7576

7677
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation()
7778
const menuTabHeightAndInsetBottomTermForShiftY = useDerivedValue(() => keyboardProgress.value * (insetBottom + MAX_TAB_BAR_HEIGHT), [insetBottom])

src/components/themed/SceneFooterWrapper.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface SceneFooterProps {
2020
export const SceneFooterWrapper = (props: SceneFooterProps) => {
2121
const { children, noBackgroundBlur = false, sceneWrapperInfo } = props
2222
const { hasTabs = true, isKeyboardOpen = false } = sceneWrapperInfo ?? {}
23-
const { footerOpenRatio } = useSceneFooterState()
23+
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
2424

2525
const handleFooterLayout = useLayoutHeightInFooter()
2626
const safeAreaInsets = useSafeAreaInsets()

src/components/themed/SearchFooter.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export const SearchFooter = (props: SearchFooterProps) => {
2727

2828
const textInputRef = React.useRef<SimpleTextInputRef>(null)
2929

30-
const { footerOpenRatio, setKeepOpen } = useSceneFooterState()
30+
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
31+
const setKeepOpen = useSceneFooterState(({ setKeepOpen }) => setKeepOpen)
3132

3233
const handleSearchChangeText = useHandler((text: string) => {
3334
onChangeText(text)

src/state/SceneFooterState.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const [SceneFooterRenderProvider, useSceneFooterRenderState] = createStat
8080
* @param deps the dependencies for the render function to trigger re-renders
8181
*/
8282
export const useSceneFooterRender = (renderFn: FooterRender = defaultFooterRender, deps: DependencyList) => {
83-
const { setRenderFooter } = useSceneFooterRenderState()
83+
const setRenderFooter = useSceneFooterRenderState(state => state.setRenderFooter)
8484

8585
// The callback will allow us to trigger a re-render when the deps change
8686
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -127,11 +127,15 @@ export const useSceneFooterRender = (renderFn: FooterRender = defaultFooterRende
127127
* hook multiple times will cause thrashing for the footer state shared values.
128128
*/
129129
export const useFooterAccordionEvents = () => {
130-
const { scrollState } = useSceneScrollContext()
130+
const scrollState = useSceneScrollContext(state => state.scrollState)
131131
const { scrollBeginEvent, scrollEndEvent, scrollMomentumBeginEvent, scrollMomentumEndEvent, scrollY } = scrollState
132132

133133
const scrollYStart = useSharedValue<number | undefined>(undefined)
134-
const { footerOpenRatio, footerOpenRatioStart, keepOpen, footerHeight = 1, snapTo } = useSceneFooterState()
134+
const footerOpenRatio = useSceneFooterState(state => state.footerOpenRatio)
135+
const footerOpenRatioStart = useSceneFooterState(state => state.footerOpenRatioStart)
136+
const keepOpen = useSceneFooterState(state => state.keepOpen)
137+
const footerHeight = useSceneFooterState(state => state.footerHeight ?? 1)
138+
const snapTo = useSceneFooterState(state => state.snapTo)
135139

136140
// This factor will convert scroll delta into footer open value delta (a 0 to 1 fraction)
137141
const scrollDeltaToRatioDeltaFactor = 1 / footerHeight
@@ -234,7 +238,7 @@ export const useFooterAccordionEvents = () => {
234238
* @returns layout handler for the component which height you want to measure
235239
*/
236240
export const useLayoutHeightInFooter = (): ((event: LayoutChangeEvent) => void) => {
237-
const { setFooterHeight } = useSceneFooterState()
241+
const setFooterHeight = useSceneFooterState(state => state.setFooterHeight)
238242

239243
const [layoutHeight, setLayoutHeight] = useState<number | undefined>(undefined)
240244

src/state/SceneScrollState.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export type SceneScrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>
5050
* the hook by the optional `isEnabled` boolean parameter.
5151
*/
5252
export const useSceneScrollHandler = (isEnabled: boolean = true): SceneScrollHandler => {
53-
const { setScrollState } = useSceneScrollContext()
53+
const setScrollState = useSceneScrollContext(state => state.setScrollState)
5454

5555
const localScrollState: ScrollState = useScrollState()
5656
const isFocused = useIsFocused()

src/state/createStateProvider.tsx

+15-11
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,29 @@
1-
import React, { useContext } from 'react'
1+
import React from 'react'
2+
import { createContext, useContextSelector } from 'use-context-selector'
23

4+
type Selector<State> = <T>(selector: (state: State) => T) => T
35
/**
46
* This creates a "state provider" component from a getter function.
57
* The function passed is a getter function to return the value for the state
68
* provider's context.
79
*
8-
* @param getValue the function to return the context value
9-
* @returns The context provider component and a useContextValue hook
10+
* @param getState the function to return the context value (state)
11+
* @returns The context provider component and a useStateSelector hook to select context state
1012
*/
11-
export function createStateProvider<Value>(getValue: () => Value): [React.FunctionComponent<{ children: React.ReactNode }>, () => Value] {
12-
const Context = React.createContext<Value | undefined>(undefined)
13+
export function createStateProvider<State>(getState: () => State): [React.FunctionComponent<{ children: React.ReactNode }>, Selector<State>] {
14+
const Context = createContext<State | undefined>(undefined)
1315
function WithContext({ children }: { children: React.ReactNode }) {
14-
const value = getValue()
16+
const value = getState()
1517
return <Context.Provider value={value}>{children}</Context.Provider>
1618
}
1719

18-
function useContextValue() {
19-
const context = useContext(Context)
20-
if (context == null) throw new Error(`Cannot call useDefinedContext outside of ${Context.displayName}`)
21-
return context
20+
function useStateSelector<T>(selector: (state: State) => T): T {
21+
const state = useContextSelector(Context, state => {
22+
if (state == null) throw new Error(`Cannot call useStateSelector outside of ${Context.displayName}`)
23+
return selector(state)
24+
})
25+
return state
2226
}
2327

24-
return [WithContext, useContextValue]
28+
return [WithContext, useStateSelector]
2529
}

0 commit comments

Comments
 (0)