From 632b4eb34a6ff3b1341e6239b1237db32ef8c8e9 Mon Sep 17 00:00:00 2001 From: bartlomiej obudzinski Date: Tue, 28 Apr 2026 11:57:50 +0200 Subject: [PATCH 1/2] perf: decompose OptionRowLHN avatar/delegate logic into a separate component --- .../LHNOptionsList/OptionRowAvatar.tsx | 88 +++++++++++++++++++ .../LHNOptionsList/OptionRowLHN.tsx | 71 +++------------ 2 files changed, 100 insertions(+), 59 deletions(-) create mode 100644 src/components/LHNOptionsList/OptionRowAvatar.tsx diff --git a/src/components/LHNOptionsList/OptionRowAvatar.tsx b/src/components/LHNOptionsList/OptionRowAvatar.tsx new file mode 100644 index 000000000000..595639db0570 --- /dev/null +++ b/src/components/LHNOptionsList/OptionRowAvatar.tsx @@ -0,0 +1,88 @@ +import React, {useMemo} from 'react'; +import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {shouldOptionShowTooltip} from '@libs/OptionsListUtils'; +import {getDelegateAccountIDFromReportAction} from '@libs/ReportActionsUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {Report} from '@src/types/onyx'; +import LHNAvatar from './LHNAvatar'; + +type OptionRowAvatarProps = { + optionItem: OptionData; + report: OnyxEntry; + isInFocusMode: boolean; + subscriptAvatarBorderColor: ColorValue; + secondaryAvatarBackgroundColor: ColorValue; + singleAvatarContainerStyle: StyleProp; +}; + +function OptionRowAvatar({optionItem, report, isInFocusMode, subscriptAvatarBorderColor, secondaryAvatarBackgroundColor, singleAvatarContainerStyle}: OptionRowAvatarProps) { + const personalDetails = usePersonalDetails(); + + const delegateAccountID = useMemo( + () => getDelegateAccountIDFromReportAction(optionItem?.parentReportAction), + // eslint-disable-next-line react-hooks/exhaustive-deps -- getDelegateAccountIDFromReportAction is a stable import; only parentReportAction determines the result + [optionItem?.parentReportAction], + ); + + // Match the header's delegate avatar logic: when a delegate exists on the + // parent report action, the header (useReportActionAvatars) shows the + // delegate's avatar as primary instead of the report owner's. + const skipDelegate = report?.type === CONST.REPORT.TYPE.INVOICE || (optionItem?.isTaskReport && !report?.chatReportID); + + const icons = useMemo(() => { + let result = optionItem?.icons ?? []; + if (!skipDelegate && delegateAccountID && personalDetails && result.length > 0) { + const delegateDetails = personalDetails[delegateAccountID]; + if (delegateDetails) { + const updatedIcons = [...result]; + const firstIcon = updatedIcons.at(0); + if (firstIcon) { + updatedIcons[0] = { + ...firstIcon, + source: delegateDetails.avatar ?? '', + name: delegateDetails.displayName ?? '', + id: delegateAccountID, + }; + } + result = updatedIcons; + } + } + + return result; + }, [optionItem?.icons, skipDelegate, delegateAccountID, personalDetails]); + + const delegateTooltipAccountID = useMemo(() => { + if (!skipDelegate && delegateAccountID && personalDetails?.[delegateAccountID] && optionItem?.icons?.length) { + return Number(optionItem.icons.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID); + } + return undefined; + }, [skipDelegate, delegateAccountID, personalDetails, optionItem?.icons]); + + const firstIcon = optionItem.icons?.at(0); + + if (!optionItem.icons?.length || !firstIcon) { + return null; + } + + return ( + + ); +} + +OptionRowAvatar.displayName = 'OptionRowAvatar'; + +export default OptionRowAvatar; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 4f230b11cbf9..47ab5064c42d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -6,7 +6,7 @@ import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; +import {useSession} from '@components/OnyxListItemProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import Text from '@components/Text'; @@ -26,9 +26,8 @@ import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; import {containsCustomEmoji as containsCustomEmojiUtils, containsOnlyCustomEmoji} from '@libs/EmojiUtils'; import FS from '@libs/Fullstory'; -import {shouldOptionShowTooltip, shouldUseBoldText} from '@libs/OptionsListUtils'; +import {shouldUseBoldText} from '@libs/OptionsListUtils'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; -import {getDelegateAccountIDFromReportAction} from '@libs/ReportActionsUtils'; import {isAdminRoom, isChatUsedForOnboarding as isChatUsedForOnboardingReportUtils, isConciergeChatReport, isGroupChat, isOneOnOneChat, isSystemChat} from '@libs/ReportUtils'; import {startSpan} from '@libs/telemetry/activeSpans'; import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; @@ -37,8 +36,8 @@ import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import LHNAvatar from './LHNAvatar'; import {useLHNTooltipContext} from './LHNTooltipContext'; +import OptionRowAvatar from './OptionRowAvatar'; import type {OptionRowLHNProps} from './types'; function OptionRowLHN({ @@ -65,7 +64,6 @@ function OptionRowLHN({ const {onboardingPurpose, onboarding, isFullscreenVisible, firstReportIDWithGBRorRBR, isScreenFocused, isReportsSplitNavigatorLast} = useLHNTooltipContext(); const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; - const personalDetails = usePersonalDetails(); const session = useSession(); const isOnboardingGuideAssigned = onboardingPurpose === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); const isChatUsedForOnboarding = isChatUsedForOnboardingReportUtils(report, onboarding, conciergeReportID, onboardingPurpose); @@ -101,45 +99,6 @@ function OptionRowLHN({ [optionItem?.alternateText], ); - const delegateAccountID = useMemo( - () => getDelegateAccountIDFromReportAction(optionItem?.parentReportAction), - // eslint-disable-next-line react-hooks/exhaustive-deps -- getDelegateAccountIDFromReportAction is a stable import; only parentReportAction determines the result - [optionItem?.parentReportAction], - ); - - // Match the header's delegate avatar logic: when a delegate exists on the - // parent report action, the header (useReportActionAvatars) shows the - // delegate's avatar as primary instead of the report owner's. - const skipDelegate = report?.type === CONST.REPORT.TYPE.INVOICE || (optionItem?.isTaskReport && !report?.chatReportID); - const icons = useMemo(() => { - let result = optionItem?.icons ?? []; - if (!skipDelegate && delegateAccountID && personalDetails && result.length > 0) { - const delegateDetails = personalDetails[delegateAccountID]; - if (delegateDetails) { - const updatedIcons = [...result]; - const firstIcon = updatedIcons.at(0); - if (firstIcon) { - updatedIcons[0] = { - ...firstIcon, - source: delegateDetails.avatar ?? '', - name: delegateDetails.displayName ?? '', - id: delegateAccountID, - }; - } - result = updatedIcons; - } - } - - return result; - }, [optionItem?.icons, skipDelegate, delegateAccountID, personalDetails]); - - const delegateTooltipAccountID = useMemo(() => { - if (!skipDelegate && delegateAccountID && personalDetails?.[delegateAccountID] && optionItem?.icons?.length) { - return Number(optionItem.icons.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID); - } - return undefined; - }, [skipDelegate, delegateAccountID, personalDetails, optionItem?.icons]); - const singleAvatarContainerStyle = [styles.actionAvatar, styles.mr3]; if (!optionItem && !isOptionFocused) { @@ -219,7 +178,6 @@ function OptionRowLHN({ const isStatusVisible = !!emojiCode && isOneOnOneChat(!isEmptyObject(report) ? report : undefined); const subscriptAvatarBorderColor = isOptionFocused ? focusedBackgroundColor : theme.sidebar; - const firstIcon = optionItem.icons?.at(0); // This is used to ensure that we display the text exactly as the user entered it when displaying LHN title, instead of parsing their text to HTML. const shouldParseFullTitle = optionItem?.parentReportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && !isGroupChat(report); @@ -325,20 +283,15 @@ function OptionRowLHN({ > - {!!optionItem.icons?.length && !!firstIcon && ( - - )} + + Date: Tue, 28 Apr 2026 15:53:31 +0200 Subject: [PATCH 2/2] fix: drop manual memoization, align singleAvatarContainerStyle type --- .../LHNOptionsList/OptionRowAvatar.tsx | 56 ++++++++----------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowAvatar.tsx b/src/components/LHNOptionsList/OptionRowAvatar.tsx index 595639db0570..9e7238b83947 100644 --- a/src/components/LHNOptionsList/OptionRowAvatar.tsx +++ b/src/components/LHNOptionsList/OptionRowAvatar.tsx @@ -1,5 +1,5 @@ -import React, {useMemo} from 'react'; -import type {ColorValue, StyleProp, ViewStyle} from 'react-native'; +import React from 'react'; +import type {ColorValue, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {shouldOptionShowTooltip} from '@libs/OptionsListUtils'; @@ -15,51 +15,41 @@ type OptionRowAvatarProps = { isInFocusMode: boolean; subscriptAvatarBorderColor: ColorValue; secondaryAvatarBackgroundColor: ColorValue; - singleAvatarContainerStyle: StyleProp; + singleAvatarContainerStyle: ViewStyle[]; }; function OptionRowAvatar({optionItem, report, isInFocusMode, subscriptAvatarBorderColor, secondaryAvatarBackgroundColor, singleAvatarContainerStyle}: OptionRowAvatarProps) { const personalDetails = usePersonalDetails(); - const delegateAccountID = useMemo( - () => getDelegateAccountIDFromReportAction(optionItem?.parentReportAction), - // eslint-disable-next-line react-hooks/exhaustive-deps -- getDelegateAccountIDFromReportAction is a stable import; only parentReportAction determines the result - [optionItem?.parentReportAction], - ); + const delegateAccountID = getDelegateAccountIDFromReportAction(optionItem?.parentReportAction); // Match the header's delegate avatar logic: when a delegate exists on the // parent report action, the header (useReportActionAvatars) shows the // delegate's avatar as primary instead of the report owner's. const skipDelegate = report?.type === CONST.REPORT.TYPE.INVOICE || (optionItem?.isTaskReport && !report?.chatReportID); - const icons = useMemo(() => { - let result = optionItem?.icons ?? []; - if (!skipDelegate && delegateAccountID && personalDetails && result.length > 0) { - const delegateDetails = personalDetails[delegateAccountID]; - if (delegateDetails) { - const updatedIcons = [...result]; - const firstIcon = updatedIcons.at(0); - if (firstIcon) { - updatedIcons[0] = { - ...firstIcon, - source: delegateDetails.avatar ?? '', - name: delegateDetails.displayName ?? '', - id: delegateAccountID, - }; - } - result = updatedIcons; + let icons = optionItem?.icons ?? []; + if (!skipDelegate && delegateAccountID && personalDetails && icons.length > 0) { + const delegateDetails = personalDetails[delegateAccountID]; + if (delegateDetails) { + const updatedIcons = [...icons]; + const firstDelegateIcon = updatedIcons.at(0); + if (firstDelegateIcon) { + updatedIcons[0] = { + ...firstDelegateIcon, + source: delegateDetails.avatar ?? '', + name: delegateDetails.displayName ?? '', + id: delegateAccountID, + }; } + icons = updatedIcons; } + } - return result; - }, [optionItem?.icons, skipDelegate, delegateAccountID, personalDetails]); - - const delegateTooltipAccountID = useMemo(() => { - if (!skipDelegate && delegateAccountID && personalDetails?.[delegateAccountID] && optionItem?.icons?.length) { - return Number(optionItem.icons.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID); - } - return undefined; - }, [skipDelegate, delegateAccountID, personalDetails, optionItem?.icons]); + let delegateTooltipAccountID: number | undefined; + if (!skipDelegate && delegateAccountID && personalDetails?.[delegateAccountID] && optionItem?.icons?.length) { + delegateTooltipAccountID = Number(optionItem.icons.at(0)?.id ?? CONST.DEFAULT_NUMBER_ID); + } const firstIcon = optionItem.icons?.at(0);