Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState} from 'react';
import {FlatList, View} from 'react-native';
import type {ListRenderItemInfo, ViewToken} from 'react-native';
Expand Down Expand Up @@ -93,9 +94,12 @@ import EmptyMoneyRequestReportPreview from './EmptyMoneyRequestReportPreview';
import type {MoneyRequestReportPreviewContentProps} from './types';

const reportAttributesSelector = (c: OnyxEntry<ReportAttributesDerivedValue>) => c?.reports;
const MAX_SCROLL_TO_INDEX_RETRIES = 5;
const SCROLL_TO_INDEX_RETRY_DELAY = 100;

function MoneyRequestReportPreviewContent({
iouReportID,
newTransactionIDs,
chatReportID,
action,
containerStyles,
Expand Down Expand Up @@ -432,7 +436,7 @@ function MoneyRequestReportPreviewContent({
thumbsUpScale.set(isApprovedAnimationRunning ? withDelay(CONST.ANIMATION_THUMBS_UP_DELAY, withSpring(1, {duration: CONST.ANIMATION_THUMBS_UP_DURATION})) : 1);
}, [isApproved, isApprovedAnimationRunning, thumbsUpScale]);

const carouselTransactions = shouldShowAccessPlaceHolder ? [] : transactions.slice(0, 11);
const carouselTransactions = useMemo(() => (shouldShowAccessPlaceHolder ? [] : transactions.slice(0, 11)), [shouldShowAccessPlaceHolder, transactions]);
const prevCarouselTransactionLength = useRef(0);

useEffect(() => {
Expand All @@ -458,6 +462,47 @@ function MoneyRequestReportPreviewContent({
const viewabilityConfig = useMemo(() => {
return {itemVisiblePercentThreshold: 100};
}, []);
const numberOfScrollToIndexFailed = useRef(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERFORMANCE-12 (docs)

The setTimeout call in onScrollToIndexFailed is not cleared when the component unmounts, which can cause memory leaks if the component is unmounted before the timeout fires.

Fix: Store the timeout ID and clear it in a cleanup function:

const timeoutRef = useRef<NodeJS.Timeout | null>(null);

const onScrollToIndexFailed = ({index}: {index: number; highestMeasuredFrameIndex: number; averageItemLength: number}) => {
    if (numberOfScrollToIndexFailed.current > 4) {
        return;
    }

    timeoutRef.current = setTimeout(() => {
        carouselRef.current?.scrollToIndex({index, animated: true, viewOffset: 2 * styles.gap2.gap});
    }, 100);
    numberOfScrollToIndexFailed.current++;
};

useEffect(() => {
    return () => {
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }
    };
}, []);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

const onScrollToIndexFailed: (info: {index: number; highestMeasuredFrameIndex: number; averageItemLength: number}) => void = ({index}) => {
// There is a probability of infinite loop so we want to make sure that it is not called more than 5 times.
if (numberOfScrollToIndexFailed.current >= MAX_SCROLL_TO_INDEX_RETRIES) {
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The hardcoded value 4 is used as a maximum retry limit without being defined as a named constant. This magic number reduces code readability and makes the intent less clear.

Fix: Define a named constant:

const MAX_SCROLL_TO_INDEX_RETRIES = 5; // Maximum number of retries before stopping

const onScrollToIndexFailed = ({index}: {index: number; highestMeasuredFrameIndex: number; averageItemLength: number}) => {
    if (numberOfScrollToIndexFailed.current >= MAX_SCROLL_TO_INDEX_RETRIES) {
        return;
    }
    // ... rest of the code
};

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}

// Sometimes scrollToIndex might be called before the item is rendered so we will re-call scrollToIndex after a small delay.
setTimeout(() => {
carouselRef.current?.scrollToIndex({index, animated: true, viewOffset: 2 * styles.gap2.gap});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-2 (docs)

The hardcoded value 100 (milliseconds) is used as a setTimeout delay without clear documentation. This magic number reduces code readability.

Fix: Define a named constant or use an existing constant from CONST:

const SCROLL_TO_INDEX_RETRY_DELAY = 100; // milliseconds to wait before retrying scrollToIndex

const onScrollToIndexFailed = ({index}: {index: number; highestMeasuredFrameIndex: number; averageItemLength: number}) => {
    if (numberOfScrollToIndexFailed.current > 4) {
        return;
    }

    setTimeout(() => {
        carouselRef.current?.scrollToIndex({index, animated: true, viewOffset: 2 * styles.gap2.gap});
    }, SCROLL_TO_INDEX_RETRY_DELAY);
    numberOfScrollToIndexFailed.current++;
};

Or check if theres an appropriate constant in CONST that can be reused.


Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

}, SCROLL_TO_INDEX_RETRY_DELAY);
numberOfScrollToIndexFailed.current++;
};

const carouselTransactionsRef = useRef(carouselTransactions);

useEffect(() => {
carouselTransactionsRef.current = carouselTransactions;
}, [carouselTransactions]);

useFocusEffect(
useCallback(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-2 (docs)

The findIndex callback performs an expensive operation (array lookup with .includes()) for every transaction without any early filtering. This could be optimized by converting newTransactionIDs to a Set for O(1) lookup instead of O(n) lookup with array .includes().

Fix: Convert to a Set for faster lookups:

useFocusEffect(
    useCallback(() => {
        const newTransactionIDsSet = new Set(newTransactionIDs);
        const index = carouselTransactions.findIndex((transaction) => newTransactionIDsSet.has(transaction.transactionID));

        if (index < 0) {
            return;
        }
        // ... rest of the code
    }, [newTransactionIDs]),
);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.has(transaction.transactionID));

if (index < 0) {
return;
}
const newTransaction = carouselTransactions.at(index);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERFORMANCE-12 (docs)

The setTimeout call inside useFocusEffect is not cleared when the component loses focus or unmounts, which can cause memory leaks and potential crashes if the component is unmounted before the timeout fires.

Fix: Return a cleanup function from the callback that clears the timeout:

useFocusEffect(
    useCallback(() => {
        const index = carouselTransactions.findIndex((transaction) => newTransactionIDs?.includes(transaction.transactionID));

        if (index < 0) {
            return;
        }
        const newTransaction = carouselTransactions.at(index);
        const timeoutId = setTimeout(() => {
            if (newTransaction?.transactionID !== carouselTransactionsRef.current.at(index)?.transactionID) {
                return;
            }
            numberOfScrollToIndexFailed.current = 0;
            carouselRef.current?.scrollToIndex({index, viewOffset: 2 * styles.gap2.gap, animated: true});
        }, CONST.ANIMATED_TRANSITION);

        return () => {
            clearTimeout(timeoutId);
        };
    }, [newTransactionIDs]),
);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

setTimeout(() => {
// If the new transaction is not available at the index it was on before the delay, avoid the scrolling
// because we are scrolling to either a wrong or unavailable transaction (which can cause crash).
if (newTransaction?.transactionID !== carouselTransactionsRef.current.at(index)?.transactionID) {
return;
}
numberOfScrollToIndexFailed.current = 0;
carouselRef.current?.scrollToIndex({index, viewOffset: 2 * styles.gap2.gap, animated: true});
}, CONST.ANIMATED_TRANSITION);

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newTransactionIDs]),
);

const onViewableItemsChanged = useRef(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
const newIndex = viewableItems.at(0)?.index;
Expand Down Expand Up @@ -840,6 +885,7 @@ function MoneyRequestReportPreviewContent({
) : (
<View style={[styles.flex1, styles.flexColumn, styles.overflowVisible]}>
<FlatList
onScrollToIndexFailed={onScrollToIndexFailed}
snapToAlignment="start"
decelerationRate="fast"
snapToInterval={reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import type {LayoutChangeEvent, ListRenderItem} from 'react-native';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import TransactionPreview from '@components/ReportActionItem/TransactionPreview';
import useNewTransactions from '@hooks/useNewTransactions';
import useOnyx from '@hooks/useOnyx';
import usePolicy from '@hooks/usePolicy';
import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
Expand Down Expand Up @@ -119,6 +122,11 @@ function MoneyRequestReportPreview({
Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo: Navigation.getActiveRoute()}));
}
}, [iouReportID, isSmallScreenWidth]);
const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${chatReportID}`, {canBeMissing: true});
const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ PERF-13 (docs)

The .map() operation creates a new array on every render. While the array content may be the same, the array reference changes, which can trigger unnecessary re-renders in child components receiving newTransactionIDs as a prop.

Fix: Wrap in useMemo to maintain referential stability:

const newTransactions = useNewTransactions(reportMetadata?.hasOnceLoadedReportActions, transactions);
const newTransactionIDs = useMemo(() => newTransactions.map((transaction) => transaction.transactionID), [newTransactions]);

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

const isFocused = useIsFocused();
// We only want to highlight the new expenses if the screen is focused.
const newTransactionIDs = isFocused ? new Set(newTransactions.map((transaction) => transaction.transactionID)) : undefined;

const renderItem: ListRenderItem<Transaction> = ({item}) => (
<TransactionPreview
Expand All @@ -140,11 +148,13 @@ function MoneyRequestReportPreview({
reportPreviewAction={action}
onPreviewPressed={openReportFromPreview}
shouldShowPayerAndReceiver={shouldShowPayerAndReceiver}
shouldHighlight={newTransactionIDs?.has(item.transactionID)}
/>
);

return (
<MoneyRequestReportPreviewContent
newTransactionIDs={newTransactionIDs}
iouReportID={iouReportID}
chatReportID={chatReportID}
iouReport={iouReport}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ type MoneyRequestReportPreviewContentProps = MoneyRequestReportPreviewContentOny

/** Callback called when the whole preview is pressed */
onPress: () => void;

/** IDs of newly added transactions */
newTransactionIDs?: Set<string>;
};

export type {MoneyRequestReportPreviewContentProps, MoneyRequestReportPreviewProps, MoneyRequestReportPreviewStyleType};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import truncate from 'lodash/truncate';
import React, {useMemo} from 'react';
import {View} from 'react-native';
import Animated from 'react-native-reanimated';
import Button from '@components/Button';
import Icon from '@components/Icon';
// eslint-disable-next-line no-restricted-imports
Expand All @@ -11,6 +12,7 @@ import ReportActionItemImages from '@components/ReportActionItem/ReportActionIte
import UserInfoCellsWithArrow from '@components/SelectionListWithSections/Search/UserInfoCellsWithArrow';
import Text from '@components/Text';
import TransactionPreviewSkeletonView from '@components/TransactionPreviewSkeletonView';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useEnvironment from '@hooks/useEnvironment';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
Expand Down Expand Up @@ -62,6 +64,7 @@ function TransactionPreviewContent({
shouldShowPayerAndReceiver,
navigateToReviewFields,
isReviewDuplicateTransactionPage = false,
shouldHighlight = false,
}: TransactionPreviewContentProps) {
const icons = useMemoizedLazyExpensifyIcons(['Folder', 'Tag']);
const theme = useTheme();
Expand Down Expand Up @@ -222,10 +225,17 @@ function TransactionPreviewContent({
const previewTextViewGap = (shouldShowCategoryOrTag || !shouldWrapDisplayAmount) && styles.gap2;
const previewTextMargin = shouldShowIOUHeader && shouldShowMerchantOrDescription && !isBillSplit && !shouldShowCategoryOrTag && styles.mbn1;

const animatedHighlightStyle = useAnimatedHighlightStyle({
shouldHighlight,
highlightColor: theme.messageHighlightBG,
backgroundColor: theme.cardBG,
shouldApplyOtherStyles: false,
});

const transactionWrapperStyles = [styles.border, styles.moneyRequestPreviewBox, (isIOUSettled || isApproved) && isSettlementOrApprovalPartial && styles.offlineFeedbackPending];

return (
<View style={[transactionWrapperStyles, containerStyles]}>
<Animated.View style={[transactionWrapperStyles, containerStyles, animatedHighlightStyle]}>
<OfflineWithFeedback
errors={walletTermsErrors}
onClose={() => offlineWithFeedbackOnClose}
Expand All @@ -236,7 +246,7 @@ function TransactionPreviewContent({
shouldDisableOpacity={isDeleted}
shouldHideOnDelete={shouldHideOnDelete}
>
<View style={[(isTransactionScanning || isWhisper) && [styles.reportPreviewBoxHoverBorder, styles.reportContainerBorderRadius]]}>
<View style={[(isTransactionScanning || isWhisper) && [styles.reportPreviewBoxHoverBorderColor, styles.reportContainerBorderRadius]]}>
<ReportActionItemImages
images={receiptImages}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
Expand Down Expand Up @@ -401,7 +411,7 @@ function TransactionPreviewContent({
)}
</View>
</OfflineWithFeedback>
</View>
</Animated.View>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function TransactionPreview(props: TransactionPreviewProps) {
iouReportID,
transactionID: transactionIDFromProps,
onPreviewPressed,
shouldHighlight,
reportPreviewAction,
contextAction,
} = props;
Expand Down Expand Up @@ -128,6 +129,7 @@ function TransactionPreview(props: TransactionPreviewProps) {
walletTermsErrors={walletTerms?.errors}
routeName={route.name}
isReviewDuplicateTransactionPage={isReviewDuplicateTransactionPage}
shouldHighlight={shouldHighlight}
/>
</PressableWithoutFeedback>
);
Expand All @@ -152,6 +154,7 @@ function TransactionPreview(props: TransactionPreviewProps) {
walletTermsErrors={walletTerms?.errors}
routeName={route.name}
reportPreviewAction={reportPreviewAction}
shouldHighlight={shouldHighlight}
isReviewDuplicateTransactionPage={isReviewDuplicateTransactionPage}
/>
);
Expand Down
6 changes: 6 additions & 0 deletions src/components/ReportActionItem/TransactionPreview/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type TransactionPreviewProps = {

/** In case we want to override context menu action */
contextAction?: OnyxEntry<ReportAction>;

/** Whether the item should be highlighted */
shouldHighlight?: boolean;
};

type TransactionPreviewContentProps = {
Expand Down Expand Up @@ -141,6 +144,9 @@ type TransactionPreviewContentProps = {

/** Is this component used during duplicate review flow */
isReviewDuplicateTransactionPage?: boolean;

/** Whether the item should be highlighted */
shouldHighlight?: boolean;
};

export type {TransactionPreviewContentProps, TransactionPreviewProps, TransactionPreviewStyleType};
7 changes: 5 additions & 2 deletions src/hooks/useAnimatedHighlightStyle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type Props = {
/** Whether the item should be highlighted */
shouldHighlight: boolean;

/** Whether it should return height and border radius styles */
shouldApplyOtherStyles?: boolean;

/** The base backgroundColor used for the highlight animation, defaults to theme.appBG
* @default theme.appBG
*/
Expand Down Expand Up @@ -63,6 +66,7 @@ export default function useAnimatedHighlightStyle({
height,
highlightColor,
backgroundColor,
shouldApplyOtherStyles = true,
skipInitialFade = false,
}: Props) {
const [startHighlight, setStartHighlight] = useState(false);
Expand All @@ -80,9 +84,8 @@ export default function useAnimatedHighlightStyle({

return {
backgroundColor: interpolateColor(repeatableValue, [0, 1], [backgroundColor ?? theme.appBG, highlightColor ?? theme.border]),
height: height ? interpolate(nonRepeatableValue, [0, 1], [0, height]) : 'auto',
opacity: interpolate(nonRepeatableValue, [0, 1], [0, 1]),
borderRadius,
...(shouldApplyOtherStyles && {height: height ? interpolate(nonRepeatableValue, [0, 1], [0, height]) : 'auto', borderRadius}),
};
}, [borderRadius, height, backgroundColor, highlightColor, theme.appBG, theme.border]);

Expand Down
4 changes: 1 addition & 3 deletions src/hooks/useNewTransactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ function useNewTransactions(hasOnceLoadedReportActions: boolean | undefined, tra
return CONST.EMPTY_ARRAY as unknown as Transaction[];
}
return transactions.filter((transaction) => !prevTransactions?.some((prevTransaction) => prevTransaction.transactionID === transaction.transactionID));
// Depending only on transactions is enough because prevTransactions is a helper object.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactions]);
}, [transactions, prevTransactions]);

// In case when we have loaded the report, but there were no transactions in it, then we need to explicitly set skipFirstTransactionsChange to false, as it will be not set in the useMemo above.
useEffect(() => {
Expand Down
4 changes: 4 additions & 0 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4345,6 +4345,10 @@ const staticStyles = (theme: ThemeColors) =>
backgroundColor: theme.cardBG,
},

reportPreviewBoxHoverBorderColor: {
borderColor: theme.cardBG,
},

reportContainerBorderRadius: {
borderRadius: variables.componentBorderRadiusLarge,
},
Expand Down
Loading