From 69f5cf8295a514bb702ae584031ce9dc24e80a4e Mon Sep 17 00:00:00 2001 From: Samuel Holmes Date: Mon, 29 Jan 2024 15:20:31 -0800 Subject: [PATCH] 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 https://github.com/reactjs/rfcs/pull/119 for more relevant information about the performance issues with the context API. --- jestSetup.js | 27 +++ package.json | 2 + src/components/common/SceneWrapper.tsx | 189 ++++++++++-------- .../navigation/HeaderBackground.tsx | 2 +- .../notification/NotificationView.tsx | 3 +- src/components/themed/MenuTabs.tsx | 5 +- src/components/themed/SceneFooterWrapper.tsx | 2 +- src/components/themed/SearchFooter.tsx | 3 +- src/state/SceneFooterState.tsx | 12 +- src/state/SceneScrollState.tsx | 2 +- src/state/createStateProvider.tsx | 26 ++- yarn.lock | 5 + 12 files changed, 176 insertions(+), 102 deletions(-) diff --git a/jestSetup.js b/jestSetup.js index 6b36be77163..89988de1e27 100644 --- a/jestSetup.js +++ b/jestSetup.js @@ -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 + } + } +}) diff --git a/package.json b/package.json index 6b10be48847..5392cbb212a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/common/SceneWrapper.tsx b/src/components/common/SceneWrapper.tsx index c7c0ef25f5d..343dfa0eea4 100644 --- a/src/components/common/SceneWrapper.tsx +++ b/src/components/common/SceneWrapper.tsx @@ -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() const theme = useTheme() @@ -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 ( - <> - - - {}} - // Fixes middle-floating scrollbar issue - scrollIndicatorInsets={{ right: 1 }} - > - - {isFuncChildren ? children(info) : children} - - - {renderFooter != null && !hasTabs ? {renderFooter(info)} : null} - - {hasNotifications ? : 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 ( + <> + + + {}} + // Fixes middle-floating scrollbar issue + scrollIndicatorInsets={{ right: 1 }} + > + + {isFuncChildren ? children(info) : children} + + + {renderFooter != null && !hasTabs ? {renderFooter(info)} : null} + + {hasNotifications ? : 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. diff --git a/src/components/navigation/HeaderBackground.tsx b/src/components/navigation/HeaderBackground.tsx index 9aaf5776d1b..cda1b55adea 100644 --- a/src/components/navigation/HeaderBackground.tsx +++ b/src/components/navigation/HeaderBackground.tsx @@ -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 ( diff --git a/src/components/notification/NotificationView.tsx b/src/components/notification/NotificationView.tsx index a7e57ba6189..8be6b2b1c54 100644 --- a/src/components/notification/NotificationView.tsx +++ b/src/components/notification/NotificationView.tsx @@ -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(state => state.footerOpenRatio) + const footerHeight = useSceneFooterState(state => state.footerHeight ?? 0) const [autoDetectTokenCards, setAutoDetectTokenCards] = React.useState([]) diff --git a/src/components/themed/MenuTabs.tsx b/src/components/themed/MenuTabs.tsx index 1049402c287..37e113943a1 100644 --- a/src/components/themed/MenuTabs.tsx +++ b/src/components/themed/MenuTabs.tsx @@ -70,8 +70,9 @@ export const MenuTabs = (props: BottomTabBarProps) => { const { bottom: insetBottom } = useSafeAreaInsets() - const { footerOpenRatio, resetFooterRatio } = useSceneFooterState() - const { renderFooter } = useSceneFooterRenderState() + const footerOpenRatio = useSceneFooterState(state => state.footerOpenRatio) + const resetFooterRatio = useSceneFooterState(state => state.resetFooterRatio) + const renderFooter = useSceneFooterRenderState(state => state.renderFooter) const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation() const menuTabHeightAndInsetBottomTermForShiftY = useDerivedValue(() => keyboardProgress.value * (insetBottom + MAX_TAB_BAR_HEIGHT), [insetBottom]) diff --git a/src/components/themed/SceneFooterWrapper.tsx b/src/components/themed/SceneFooterWrapper.tsx index 26ff4475689..17d23e7e2b0 100644 --- a/src/components/themed/SceneFooterWrapper.tsx +++ b/src/components/themed/SceneFooterWrapper.tsx @@ -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(state => state.footerOpenRatio) const handleFooterLayout = useLayoutHeightInFooter() const safeAreaInsets = useSafeAreaInsets() diff --git a/src/components/themed/SearchFooter.tsx b/src/components/themed/SearchFooter.tsx index b15615590db..7c929cca4be 100644 --- a/src/components/themed/SearchFooter.tsx +++ b/src/components/themed/SearchFooter.tsx @@ -27,7 +27,8 @@ export const SearchFooter = (props: SearchFooterProps) => { const textInputRef = React.useRef(null) - const { footerOpenRatio, setKeepOpen } = useSceneFooterState() + const footerOpenRatio = useSceneFooterState(state => state.footerOpenRatio) + const setKeepOpen = useSceneFooterState(state => state.setKeepOpen) const handleSearchChangeText = useHandler((text: string) => { onChangeText(text) diff --git a/src/state/SceneFooterState.tsx b/src/state/SceneFooterState.tsx index a72efade0b7..3ca8d542b4a 100644 --- a/src/state/SceneFooterState.tsx +++ b/src/state/SceneFooterState.tsx @@ -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 @@ -127,11 +127,15 @@ export const useSceneFooterRender = (renderFn: FooterRender = defaultFooterRende * thrashing for the footer state shared values. */ export const FooterAccordionEventService = () => { - const { scrollState } = useSceneScrollContext() + const scrollState = useSceneScrollContext(state => state.scrollState) const { scrollBeginEvent, scrollEndEvent, scrollMomentumBeginEvent, scrollMomentumEndEvent, scrollY } = scrollState const scrollYStart = useSharedValue(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 @@ -236,7 +240,7 @@ export const FooterAccordionEventService = () => { * @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(undefined) diff --git a/src/state/SceneScrollState.tsx b/src/state/SceneScrollState.tsx index 286c563c24a..fcff4f893d9 100644 --- a/src/state/SceneScrollState.tsx +++ b/src/state/SceneScrollState.tsx @@ -50,7 +50,7 @@ export type SceneScrollHandler = (event: NativeSyntheticEvent * 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() diff --git a/src/state/createStateProvider.tsx b/src/state/createStateProvider.tsx index 697e6d3aba5..0af0350ee52 100644 --- a/src/state/createStateProvider.tsx +++ b/src/state/createStateProvider.tsx @@ -1,25 +1,29 @@ -import React, { useContext } from 'react' +import React from 'react' +import { createContext, useContextSelector } from 'use-context-selector' +type Selector = (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(getValue: () => Value): [React.FunctionComponent<{ children: React.ReactNode }>, () => Value] { - const Context = React.createContext(undefined) +export function createStateProvider(getState: () => State): [React.FunctionComponent<{ children: React.ReactNode }>, Selector] { + const Context = createContext(undefined) function WithContext({ children }: { children: React.ReactNode }) { - const value = getValue() + const value = getState() return {children} } - function useContextValue() { - const context = useContext(Context) - if (context == null) throw new Error(`Cannot call useDefinedContext outside of ${Context.displayName}`) - return context + function useStateSelector(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] } diff --git a/yarn.lock b/yarn.lock index 502e738b680..424682b2cb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18067,6 +18067,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-context-selector@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/use-context-selector/-/use-context-selector-1.4.1.tgz#eb96279965846b72915d7f899b8e6ef1d768b0ae" + integrity sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA== + use-latest-callback@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.5.tgz#a4a836c08fa72f6608730b5b8f4bbd9c57c04f51"