Skip to content

Commit

Permalink
Convert state providers to use use-context-selector
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
samholmes committed Jan 26, 2024
1 parent dbf3632 commit 7d60920
Show file tree
Hide file tree
Showing 12 changed files with 178 additions and 104 deletions.
27 changes: 27 additions & 0 deletions jestSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,30 @@ jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock
for (const log in global.console) {
global.console[log] = jest.fn()
}

jest.mock('use-context-selector', () => {
const contextValues = new Map()
return {
createContext: defaultValue => {
// Create new provider
const Provider = (props, context) => {
contextValues.set(Provider, props.value)
return props.children
}
// Get the value for the provider:
const currentValue = contextValues.get(Provider)
// Set it's default value:
contextValues.set(Provider, currentValue ?? defaultValue)
// Return provider
return {
Provider: Provider,
displayName: 'test'
}
},
useContextSelector: (context, selector) => {
const currentValue = contextValues.get(context.Provider)
const selected = selector(currentValue)
return selected
}
}
})
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
"posthog-react-native": "^2.8.1",
"prompts": "^2.4.2",
"qrcode-generator": "^1.4.4",
"react": "18.2.0",
"react": "^18.2.0",
"react-native": "0.71.15",
"react-native-airship": "^0.2.12",
"react-native-bootsplash": "^4.7.4",
Expand Down Expand Up @@ -206,10 +206,12 @@
"redux-thunk": "^2.3.0",
"rn-id-blurview": "^1.2.1",
"rn-qr-generator": "^1.3.1",
"scheduler": "^0.23.0",
"sha.js": "^2.4.11",
"sprintf-js": "^1.1.1",
"url": "^0.11.0",
"url-parse": "^1.5.2",
"use-context-selector": "^1.4.1",
"yaob": "^0.3.12",
"yavent": "^0.1.3"
},
Expand Down
189 changes: 109 additions & 80 deletions src/components/common/SceneWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function SceneWrapper(props: SceneWrapperProps): JSX.Element {
const activeUsername = useSelector(state => state.core.account.username)
const isLightAccount = accountId != null && activeUsername == null

const { footerHeight = 0 } = useSceneFooterState()
const footerHeight = useSceneFooterState(({ footerHeight = 0 }) => footerHeight)

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

const notificationHeight = theme.rem(4)
const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)

// If the scene has scroll, this will be required for tabs and/or header animation
const handleScroll = useSceneScrollHandler(scroll && (hasTabs || hasHeader))

const { renderFooter } = useSceneFooterRenderState()

const renderScene = (keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
// If function children, the caller handles the insets and overscroll
const isFuncChildren = typeof children === 'function'

// Derive the keyboard height by getting the difference between screen height
// and trackerValue. This value should be from zero to keyboard height
// depending on the open state of the keyboard
const keyboardHeight = frame.height - trackerValue
const isKeyboardOpen = avoidKeyboard && keyboardHeight !== 0

// Calculate app insets considering the app's header, tab-bar,
// notification area, etc:
const maybeHeaderHeight = hasHeader ? headerBarHeight : 0
const maybeNotificationHeight = isLightAccount ? notificationHeight : 0
const maybeTabBarHeight = hasTabs ? MAX_TAB_BAR_HEIGHT : 0
const maybeInsetBottom = !hasTabs && !isKeyboardOpen ? safeAreaInsets.bottom : 0
const insets: EdgeInsets = {
top: safeAreaInsets.top + maybeHeaderHeight,
right: safeAreaInsets.right,
bottom: maybeInsetBottom + maybeNotificationHeight + maybeTabBarHeight + footerHeight,
left: safeAreaInsets.left
}

// This is a convenient styles object which may be applied as
// contentContainerStyles for child scroll components. It will also be
// used for the ScrollView component internal to the SceneWrapper.
const insetStyle: InsetStyle = {
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left
}

// This is a convenient styles object which may be applied to scene container
// components to offset the inset styles applied to the SceneWrapper.
const undoInsetStyle: UndoInsetStyle = {
flex: 1,
marginTop: -insets.top,
marginRight: -insets.right,
marginBottom: -insets.bottom,
marginLeft: -insets.left
}

const info: SceneWrapperInfo = { insets, insetStyle, undoInsetStyle, hasTabs, isKeyboardOpen }

return (
<>
<MaybeAnimatedView when={avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { maxHeight: keyboardAnimation, padding }]}>
<DotsBackground
accentColors={accentColors}
overrideDots={overrideDots}
backgroundGradientColors={backgroundGradientColors}
backgroundGradientStart={backgroundGradientStart}
backgroundGradientEnd={backgroundGradientEnd}
/>
<MaybeAnimatedScrollView
when={scroll && !avoidKeyboard}
style={[layoutStyle, { padding }]}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
contentContainerStyle={insetStyle}
onScroll={hasTabs || hasHeader ? handleScroll : () => {}}
// Fixes middle-floating scrollbar issue
scrollIndicatorInsets={{ right: 1 }}
>
<MaybeView when={!scroll && !avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { padding }]}>
{isFuncChildren ? children(info) : children}
</MaybeView>
</MaybeAnimatedScrollView>
{renderFooter != null && !hasTabs ? <SceneFooter>{renderFooter(info)}</SceneFooter> : null}
</MaybeAnimatedView>
{hasNotifications ? <NotificationView hasTabs={hasTabs} navigation={navigation} /> : null}
</>
)
}
const renderFooter = useSceneFooterRenderState(({ renderFooter }) => renderFooter)

const renderScene = React.useCallback(
(keyboardAnimation: Animated.Value | undefined, trackerValue: number): JSX.Element => {
const notificationHeight = theme.rem(4)
const headerBarHeight = getDefaultHeaderHeight(frame, false, 0)

// If function children, the caller handles the insets and overscroll
const isFuncChildren = typeof children === 'function'

// Derive the keyboard height by getting the difference between screen height
// and trackerValue. This value should be from zero to keyboard height
// depending on the open state of the keyboard
const keyboardHeight = frame.height - trackerValue
const isKeyboardOpen = avoidKeyboard && keyboardHeight !== 0

// Calculate app insets considering the app's header, tab-bar,
// notification area, etc:
const maybeHeaderHeight = hasHeader ? headerBarHeight : 0
const maybeNotificationHeight = isLightAccount ? notificationHeight : 0
const maybeTabBarHeight = hasTabs ? MAX_TAB_BAR_HEIGHT : 0
const maybeInsetBottom = !hasTabs && !isKeyboardOpen ? safeAreaInsets.bottom : 0
const insets: EdgeInsets = {
top: safeAreaInsets.top + maybeHeaderHeight,
right: safeAreaInsets.right,
bottom: maybeInsetBottom + maybeNotificationHeight + maybeTabBarHeight + footerHeight,
left: safeAreaInsets.left
}

// This is a convenient styles object which may be applied as
// contentContainerStyles for child scroll components. It will also be
// used for the ScrollView component internal to the SceneWrapper.
const insetStyle: InsetStyle = {
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left
}

// This is a convenient styles object which may be applied to scene container
// components to offset the inset styles applied to the SceneWrapper.
const undoInsetStyle: UndoInsetStyle = {
flex: 1,
marginTop: -insets.top,
marginRight: -insets.right,
marginBottom: -insets.bottom,
marginLeft: -insets.left
}

const info: SceneWrapperInfo = { insets, insetStyle, undoInsetStyle, hasTabs, isKeyboardOpen }

return (
<>
<MaybeAnimatedView when={avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { maxHeight: keyboardAnimation, padding }]}>
<DotsBackground
accentColors={accentColors}
overrideDots={overrideDots}
backgroundGradientColors={backgroundGradientColors}
backgroundGradientStart={backgroundGradientStart}
backgroundGradientEnd={backgroundGradientEnd}
/>
<MaybeAnimatedScrollView
when={scroll && !avoidKeyboard}
style={[layoutStyle, { padding }]}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
contentContainerStyle={insetStyle}
onScroll={hasTabs || hasHeader ? handleScroll : () => {}}
// Fixes middle-floating scrollbar issue
scrollIndicatorInsets={{ right: 1 }}
>
<MaybeView when={!scroll && !avoidKeyboard} style={[styles.sceneContainer, layoutStyle, insetStyle, { padding }]}>
{isFuncChildren ? children(info) : children}
</MaybeView>
</MaybeAnimatedScrollView>
{renderFooter != null && !hasTabs ? <SceneFooter>{renderFooter(info)}</SceneFooter> : null}
</MaybeAnimatedView>
{hasNotifications ? <NotificationView hasTabs={hasTabs} navigation={navigation} /> : null}
</>
)
},
[
accentColors,
avoidKeyboard,
backgroundGradientColors,
backgroundGradientEnd,
backgroundGradientStart,
children,
footerHeight,
frame,
handleScroll,
hasHeader,
hasNotifications,
hasTabs,
isLightAccount,
keyboardShouldPersistTaps,
layoutStyle,
navigation,
overrideDots,
padding,
renderFooter,
safeAreaInsets.bottom,
safeAreaInsets.left,
safeAreaInsets.right,
safeAreaInsets.top,
scroll,
theme
]
)

// These represent the distance from the top of the screen to the top of
// the keyboard depending if the keyboard is down or up.
Expand Down
2 changes: 1 addition & 1 deletion src/components/navigation/HeaderBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { BlurBackground } from '../ui4/BlurBackground'
export const HeaderBackground = (props: any) => {
const theme = useTheme()

const { scrollState } = useSceneScrollContext()
const scrollState = useSceneScrollContext(state => state.scrollState)

return (
<HeaderBackgroundContainerView scrollY={scrollState.scrollY}>
Expand Down
3 changes: 2 additions & 1 deletion src/components/notification/NotificationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const NotificationViewComponent = (props: Props) => {
const isBackupWarningShown = account.id != null && account.username == null

const { bottom: insetBottom } = useSafeAreaInsets()
const { footerOpenRatio, footerHeight = 0 } = useSceneFooterState()
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
const footerHeight = useSceneFooterState(({ footerHeight = 0 }) => footerHeight)

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

Expand Down
5 changes: 3 additions & 2 deletions src/components/themed/MenuTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export const MenuTabs = (props: BottomTabBarProps) => {

const { bottom: insetBottom } = useSafeAreaInsets()

const { footerOpenRatio, resetFooterRatio } = useSceneFooterState()
const { renderFooter } = useSceneFooterRenderState()
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
const resetFooterRatio = useSceneFooterState(({ resetFooterRatio }) => resetFooterRatio)
const renderFooter = useSceneFooterRenderState(({ renderFooter }) => renderFooter)

const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation()
const menuTabHeightAndInsetBottomTermForShiftY = useDerivedValue(() => keyboardProgress.value * (insetBottom + MAX_TAB_BAR_HEIGHT), [insetBottom])
Expand Down
2 changes: 1 addition & 1 deletion src/components/themed/SceneFooterWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface SceneFooterProps {
export const SceneFooterWrapper = (props: SceneFooterProps) => {
const { children, noBackgroundBlur = false, sceneWrapperInfo } = props
const { hasTabs = true, isKeyboardOpen = false } = sceneWrapperInfo ?? {}
const { footerOpenRatio } = useSceneFooterState()
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)

const handleFooterLayout = useLayoutHeightInFooter()
const safeAreaInsets = useSafeAreaInsets()
Expand Down
3 changes: 2 additions & 1 deletion src/components/themed/SearchFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export const SearchFooter = (props: SearchFooterProps) => {

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

const { footerOpenRatio, setKeepOpen } = useSceneFooterState()
const footerOpenRatio = useSceneFooterState(({ footerOpenRatio }) => footerOpenRatio)
const setKeepOpen = useSceneFooterState(({ setKeepOpen }) => setKeepOpen)

const handleSearchChangeText = useHandler((text: string) => {
onChangeText(text)
Expand Down
12 changes: 8 additions & 4 deletions src/state/SceneFooterState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const [SceneFooterRenderProvider, useSceneFooterRenderState] = createStat
* @param deps the dependencies for the render function to trigger re-renders
*/
export const useSceneFooterRender = (renderFn: FooterRender = defaultFooterRender, deps: DependencyList) => {
const { setRenderFooter } = useSceneFooterRenderState()
const setRenderFooter = useSceneFooterRenderState(state => state.setRenderFooter)

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

const scrollYStart = useSharedValue<number | undefined>(undefined)
const { footerOpenRatio, footerOpenRatioStart, keepOpen, footerHeight = 1, snapTo } = useSceneFooterState()
const footerOpenRatio = useSceneFooterState(state => state.footerOpenRatio)
const footerOpenRatioStart = useSceneFooterState(state => state.footerOpenRatioStart)
const keepOpen = useSceneFooterState(state => state.keepOpen)
const footerHeight = useSceneFooterState(state => state.footerHeight ?? 1)
const snapTo = useSceneFooterState(state => state.snapTo)

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

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

Expand Down
2 changes: 1 addition & 1 deletion src/state/SceneScrollState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type SceneScrollHandler = (event: NativeSyntheticEvent<NativeScrollEvent>
* the hook by the optional `isEnabled` boolean parameter.
*/
export const useSceneScrollHandler = (isEnabled: boolean = true): SceneScrollHandler => {
const { setScrollState } = useSceneScrollContext()
const setScrollState = useSceneScrollContext(state => state.setScrollState)

const localScrollState: ScrollState = useScrollState()
const isFocused = useIsFocused()
Expand Down
26 changes: 15 additions & 11 deletions src/state/createStateProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import React, { useContext } from 'react'
import React from 'react'
import { createContext, useContextSelector } from 'use-context-selector'

type Selector<State> = <T>(selector: (state: State) => T) => T
/**
* This creates a "state provider" component from a getter function.
* The function passed is a getter function to return the value for the state
* provider's context.
*
* @param getValue the function to return the context value
* @returns The context provider component and a useContextValue hook
* @param getState the function to return the context value (state)
* @returns The context provider component and a useStateSelector hook to select context state
*/
export function createStateProvider<Value>(getValue: () => Value): [React.FunctionComponent<{ children: React.ReactNode }>, () => Value] {
const Context = React.createContext<Value | undefined>(undefined)
export function createStateProvider<State>(getState: () => State): [React.FunctionComponent<{ children: React.ReactNode }>, Selector<State>] {
const Context = createContext<State | undefined>(undefined)
function WithContext({ children }: { children: React.ReactNode }) {
const value = getValue()
const value = getState()
return <Context.Provider value={value}>{children}</Context.Provider>
}

function useContextValue() {
const context = useContext(Context)
if (context == null) throw new Error(`Cannot call useDefinedContext outside of ${Context.displayName}`)
return context
function useStateSelector<T>(selector: (state: State) => T): T {
const state = useContextSelector(Context, state => {
if (state == null) throw new Error(`Cannot call useStateSelector outside of ${Context.displayName}`)
return selector(state)
})
return state
}

return [WithContext, useContextValue]
return [WithContext, useStateSelector]
}
Loading

0 comments on commit 7d60920

Please sign in to comment.