diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 0f33a3685b1c..7eb0ee7286c9 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -1,11 +1,11 @@ import React, {forwardRef, useState} from 'react'; import _ from 'underscore'; import propTypes from 'prop-types'; -import {InteractionManager} from 'react-native'; import GenericPressable from './GenericPressable'; import GenericPressablePropTypes from './GenericPressable/PropTypes'; import OpacityView from '../OpacityView'; import variables from '../../styles/variables'; +import useSingleExecution from '../../hooks/useSingleExecution'; const omittedProps = ['wrapperStyle']; @@ -39,7 +39,7 @@ const PressableWithFeedbackDefaultProps = { const PressableWithFeedback = forwardRef((props, ref) => { const propsWithoutWrapperStyles = _.omit(props, omittedProps); - const [isExecuting, setIsExecuting] = useState(false); + const {isExecuting, singleExecution} = useSingleExecution(); const [isPressed, setIsPressed] = useState(false); const [isHovered, setIsHovered] = useState(false); const isDisabled = props.disabled || isExecuting; @@ -73,17 +73,7 @@ const PressableWithFeedback = forwardRef((props, ref) => { if (props.onPressOut) props.onPressOut(); }} onPress={(e) => { - setIsExecuting(true); - const onPress = props.onPress(e); - InteractionManager.runAfterInteractions(() => { - if (!(onPress instanceof Promise)) { - setIsExecuting(false); - return; - } - onPress.finally(() => { - setIsExecuting(false); - }); - }); + singleExecution(() => props.onPress(e))(); }} > {(state) => (_.isFunction(props.children) ? props.children(state) : props.children)} diff --git a/src/hooks/useSingleExecution.js b/src/hooks/useSingleExecution.js new file mode 100644 index 000000000000..22c2907c9420 --- /dev/null +++ b/src/hooks/useSingleExecution.js @@ -0,0 +1,35 @@ +import {InteractionManager} from 'react-native'; +import {useCallback, useState} from 'react'; + +/** + * With any action passed in, it will only allow 1 such action to occur at a time. + * + * @returns {Object} + */ +export default function useSingleExecution() { + const [isExecuting, setIsExecuting] = useState(false); + + const singleExecution = useCallback( + (action) => () => { + if (isExecuting) { + return; + } + + setIsExecuting(true); + + const execution = action(); + InteractionManager.runAfterInteractions(() => { + if (!(execution instanceof Promise)) { + setIsExecuting(false); + return; + } + execution.finally(() => { + setIsExecuting(false); + }); + }); + }, + [isExecuting], + ); + + return {isExecuting, singleExecution}; +} diff --git a/src/hooks/useWaitForNavigation.js b/src/hooks/useWaitForNavigation.js new file mode 100644 index 000000000000..00f4405dff12 --- /dev/null +++ b/src/hooks/useWaitForNavigation.js @@ -0,0 +1,33 @@ +import {useEffect, useRef} from 'react'; +import {useNavigation} from '@react-navigation/native'; + +/** + * Returns a promise that resolves when navigation finishes. + * Only use when navigating by react-navigation + * + * @returns {function} + */ +export default function useWaitForNavigation() { + const navigation = useNavigation(); + const resolvePromises = useRef([]); + + useEffect(() => { + const unsubscribeBlur = navigation.addListener('blur', () => { + resolvePromises.current.forEach((resolve) => { + resolve(); + }); + resolvePromises.current = []; + }); + + return () => { + unsubscribeBlur(); + }; + }, [navigation]); + + return (navigate) => () => { + navigate(); + return new Promise((resolve) => { + resolvePromises.current.push(resolve); + }); + }; +} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 783c69a08ed9..a67e7cbc122e 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -41,6 +41,8 @@ import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions' import * as CurrencyUtils from '../../libs/CurrencyUtils'; import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback'; import useLocalize from '../../hooks/useLocalize'; +import useSingleExecution from '../../hooks/useSingleExecution'; +import useWaitForNavigation from '../../hooks/useWaitForNavigation'; const propTypes = { /* Onyx Props */ @@ -125,6 +127,8 @@ const defaultProps = { }; function InitialSettingsPage(props) { + const {isExecuting, singleExecution} = useSingleExecution(); + const waitForNavigate = useWaitForNavigation(); const popoverAnchor = useRef(null); const {translate} = useLocalize(); @@ -186,16 +190,16 @@ function InitialSettingsPage(props) { { translationKey: 'common.shareCode', icon: Expensicons.QrCode, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE); - }, + }), }, { translationKey: 'common.workspaces', icon: Expensicons.Building, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); - }, + }), floatRightAvatars: policiesAvatars, shouldStackHorizontally: true, avatarSize: CONST.AVATAR_SIZE.SMALLER, @@ -204,31 +208,31 @@ function InitialSettingsPage(props) { { translationKey: 'common.profile', icon: Expensicons.Profile, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_PROFILE); - }, + }), brickRoadIndicator: profileBrickRoadIndicator, }, { translationKey: 'common.preferences', icon: Expensicons.Gear, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); - }, + }), }, { translationKey: 'initialSettingsPage.security', icon: Expensicons.Lock, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_SECURITY); - }, + }), }, { translationKey: 'common.wallet', icon: Expensicons.Wallet, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_WALLET); - }, + }), brickRoadIndicator: PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || !_.isEmpty(props.userWallet.errors) || !_.isEmpty(props.walletTerms.errors) ? 'error' @@ -247,9 +251,9 @@ function InitialSettingsPage(props) { { translationKey: 'initialSettingsPage.about', icon: Expensicons.Info, - action: () => { + action: waitForNavigate(() => { Navigation.navigate(ROUTES.SETTINGS_ABOUT); - }, + }), }, { translationKey: 'initialSettingsPage.signOut', @@ -270,6 +274,7 @@ function InitialSettingsPage(props) { props.userWallet.errors, props.walletTerms.errors, signOut, + waitForNavigate, ]); const getMenuItems = useMemo(() => { @@ -292,7 +297,8 @@ function InitialSettingsPage(props) { title={keyTitle} icon={item.icon} iconType={item.iconType} - onPress={item.action} + disabled={isExecuting} + onPress={singleExecution(item.action)} iconStyles={item.iconStyles} shouldShowRightIcon iconRight={item.iconRight} @@ -312,7 +318,7 @@ function InitialSettingsPage(props) { })} ); - }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate]); + }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate, isExecuting, singleExecution]); // On the very first sign in or after clearing storage these // details will not be present on the first render so we'll just