From ea46233abbbd70999be22ef1b9b3c7067cb479ad Mon Sep 17 00:00:00 2001 From: Cristi Paval Date: Mon, 30 Mar 2026 16:17:38 +0300 Subject: [PATCH] Revert "[Perf] Scope REPORT_ACTIONS subscription to per-row level in LHNOptionsList " --- .../LHNOptionsList/LHNOptionsList.tsx | 92 +++++++- .../LHNOptionsList/OptionRowLHNData.tsx | 201 ++++-------------- src/components/LHNOptionsList/types.ts | 29 ++- 3 files changed, 154 insertions(+), 168 deletions(-) diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index c359ab10f1bb2..c10fb9e875b02 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -5,6 +5,7 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import type {BlockingViewProps} from '@components/BlockingViews/BlockingView'; import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; @@ -23,6 +24,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; +import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction} from '@libs/ReportActionsUtils'; +import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -47,11 +50,15 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const reportAttributes = useReportAttributes(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [isFullscreenVisible] = useOnyx(ONYXKEYS.FULLSCREEN_VISIBILITY); + const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const theme = useTheme(); @@ -153,8 +160,12 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const renderItem = useCallback( ({item, index}: RenderItemProps): ReactElement => { const reportID = item.reportID; + const itemReportAttributes = reportAttributes?.[reportID]; const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; const itemReportNameValuePairs = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; + const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; + const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${item?.parentReportID}`]; + const itemParentReportAction = item?.parentReportActionID ? itemParentReportActions?.[item?.parentReportActionID] : undefined; let invoiceReceiverPolicyID = '-1'; if (item?.invoiceReceiver && 'policyID' in item.invoiceReceiver) { @@ -166,19 +177,54 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`]; const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item?.policyID}`]; + const hasDraftComment = + !!draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] && + !draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]?.match(CONST.REGEX.EMPTY_COMMENT); + + const isReportArchived = !!itemReportNameValuePairs?.private_isArchived; + const canUserPerformWrite = canUserPerformWriteActionUtil(item, isReportArchived); + + const lastAction = getLastVisibleActionIncludingTransactionThread( + reportID, + canUserPerformWrite, + reportActions, + visibleReportActionsData, + itemOneTransactionThreadReport?.reportID, + ); + + // Only override lastMessageTextFromReport when a track expense whisper's transaction has been deleted, to prevent showing stale text. + let lastMessageTextFromReport: string | undefined; + if (isActionableTrackExpense(lastAction)) { + const whisperTransactionID = getOriginalMessage(lastAction)?.transactionID; + if (whisperTransactionID && !transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${whisperTransactionID}`]) { + lastMessageTextFromReport = ''; + } + } const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; + let lastActionReport: OnyxEntry | undefined; + if (isInviteOrRemovedAction(lastAction)) { + const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; + lastActionReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${lastActionOriginalMessage?.reportID}`]; + } + return ( ); }, [ + reportAttributes, reports, reportNameValuePairs, + reportActions, policy, + transactions, + draftComments, personalDetails, firstReportIDWithGBRorRBR, isFullscreenVisible, @@ -210,13 +263,45 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio isScreenFocused, localeCompare, translate, + visibleReportActionsData, currentUserAccountID, ], ); const extraData = useMemo( - () => [reports, reportNameValuePairs, policy, personalDetails, data.length, optionMode, isOffline, isScreenFocused, isReportsSplitNavigatorLast], - [reports, reportNameValuePairs, policy, personalDetails, data.length, optionMode, isOffline, isScreenFocused, isReportsSplitNavigatorLast], + () => [ + reportActions, + reportAttributes, + reports, + reportAttributes, + reportNameValuePairs, + policy, + personalDetails, + data.length, + optionMode, + transactions, + draftComments, + isOffline, + isScreenFocused, + isReportsSplitNavigatorLast, + visibleReportActionsData, + ], + [ + reportActions, + reportAttributes, + reports, + reportNameValuePairs, + policy, + personalDetails, + data.length, + optionMode, + transactions, + draftComments, + isOffline, + isScreenFocused, + isReportsSplitNavigatorLast, + visibleReportActionsData, + ], ); const previousOptionMode = usePrevious(optionMode); @@ -271,13 +356,14 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio if (shouldShowEmptyLHN) { Log.info('Woohoo! All caught up. Was rendered', false, { reportsCount: Object.keys(reports ?? {}).length, + reportActionsCount: Object.keys(reportActions ?? {}).length, policyCount: Object.keys(policy ?? {}).length, personalDetailsCount: Object.keys(personalDetails ?? {}).length, route, reportsIDsFromUseReportsCount: data.length, }); } - }, [data.length, shouldShowEmptyLHN, route, reports, policy, personalDetails]); + }, [data.length, shouldShowEmptyLHN, route, reports, reportActions, policy, personalDetails]); return ( diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index fa0c33618a99f..32b2417010847 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -1,20 +1,14 @@ -import React, {useCallback, useMemo} from 'react'; -import type {OnyxCollection} from 'react-native-onyx'; +import React, {useMemo} from 'react'; import useReportPreviewSenderID from '@components/ReportActionAvatars/useReportPreviewSenderID'; import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetExpensifyCardFromReportAction from '@hooks/useGetExpensifyCardFromReportAction'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getIOUReportIDOfLastAction} from '@libs/OptionsListUtils'; -import {getLastVisibleActionIncludingTransactionThread, getOriginalMessage, isActionableTrackExpense, isInviteOrRemovedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import CONST from '@src/CONST'; import {getMovedReportID} from '@src/libs/ModifiedExpenseMessage'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActions as ReportActionsType, VisibleReportActionsDerivedValue} from '@src/types/onyx'; -import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import OptionRowLHN from './OptionRowLHN'; import type {OptionRowLHNDataProps} from './types'; @@ -28,109 +22,26 @@ import type {OptionRowLHNDataProps} from './types'; function OptionRowLHNData({ isOptionFocused = false, fullReport, + reportAttributes, + reportAttributesDerived, + oneTransactionThreadReport, reportNameValuePairs, personalDetails = {}, policy, invoiceReceiverPolicy, + parentReportAction, + lastMessageTextFromReport, localeCompare, translate, + isReportArchived = false, + lastAction, + lastActionReport, currentUserAccountID, ...propsToForward }: OptionRowLHNDataProps) { const reportID = propsToForward.reportID; const {currentReportID: currentReportIDValue} = useCurrentReportIDState(); const isReportFocused = isOptionFocused && currentReportIDValue === reportID; - // Per-item scoped subscriptions - const reportAttributesSelector = useCallback((data: ReportAttributesDerivedValue | undefined) => data?.reports?.[reportID], [reportID]); - const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {selector: reportAttributesSelector}); - - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); - const hasDraftComment = !!draftComment && !draftComment.match(CONST.REGEX.EMPTY_COMMENT); - - // Use the derived thread ID directly — available even when the child report object isn't hydrated yet - const oneTransactionThreadReportID = reportAttributes?.oneTransactionThreadReportID; - - // Full report object needed only for SidebarUtils.getOptionData - const [oneTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(oneTransactionThreadReportID)}`); - - // Per-item report actions subscriptions (scoped by specific report ID) - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(fullReport?.parentReportID)}`); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(oneTransactionThreadReportID)}`); - - // Scoped VISIBLE_REPORT_ACTIONS selector — only picks entries for this report and its transaction thread. - // Onyx uses deepEqual internally for selector output comparison, so creating a new object is fine. - const visibleActionsSelector = useCallback( - (data: VisibleReportActionsDerivedValue | undefined) => { - if (!data) { - return undefined; - } - const result: VisibleReportActionsDerivedValue = {}; - const reportEntry = data[reportID]; - if (reportEntry) { - result[reportID] = reportEntry; - } - if (oneTransactionThreadReportID) { - const txThreadEntry = data[oneTransactionThreadReportID]; - if (txThreadEntry) { - result[oneTransactionThreadReportID] = txThreadEntry; - } - } - return result; - }, - [reportID, oneTransactionThreadReportID], - ); - const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS, {selector: visibleActionsSelector}); - - const [reportNameValuePairsEntry] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - - const parentReportAction = fullReport?.parentReportActionID ? parentReportActions?.[fullReport.parentReportActionID] : undefined; - - const transactionID = isMoneyRequestAction(parentReportAction) ? (getOriginalMessage(parentReportAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID !== CONST.DEFAULT_NUMBER_ID ? String(transactionID) : undefined)}`); - - const isReportArchived = !!(reportNameValuePairsEntry ?? reportNameValuePairs)?.private_isArchived; - const canUserPerformWrite = canUserPerformWriteActionUtil(fullReport, isReportArchived); - - const lastAction = useMemo(() => { - const actionsCollection: OnyxCollection = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]: reportActions ?? undefined, - }; - if (oneTransactionThreadReportID) { - actionsCollection[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oneTransactionThreadReportID}`] = transactionThreadReportActions ?? undefined; - } - return getLastVisibleActionIncludingTransactionThread(reportID, canUserPerformWrite, actionsCollection, visibleReportActionsData, oneTransactionThreadReportID); - }, [reportID, canUserPerformWrite, reportActions, transactionThreadReportActions, visibleReportActionsData, oneTransactionThreadReportID]); - - const iouReportIDOfLastAction = useMemo( - () => getIOUReportIDOfLastAction(fullReport, (reportNameValuePairsEntry ?? reportNameValuePairs)?.private_isArchived, visibleReportActionsData, lastAction), - [fullReport, reportNameValuePairsEntry, reportNameValuePairs, visibleReportActionsData, lastAction], - ); - const [iouReportReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportIDOfLastAction)}`); - - const lastReportActionTransactionID = isMoneyRequestAction(lastAction) ? (getOriginalMessage(lastAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; - const [lastReportActionTransaction] = useOnyx( - `${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(lastReportActionTransactionID !== CONST.DEFAULT_NUMBER_ID ? String(lastReportActionTransactionID) : undefined)}`, - ); - - const whisperTransactionID = isActionableTrackExpense(lastAction) ? getOriginalMessage(lastAction)?.transactionID : undefined; - const [whisperTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(whisperTransactionID)}`); - - const lastMessageTextFromReport = useMemo(() => { - if (whisperTransactionID && !whisperTransaction) { - return ''; - } - return undefined; - }, [whisperTransactionID, whisperTransaction]); - - const lastActionReportID = useMemo(() => { - if (isInviteOrRemovedAction(lastAction)) { - const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; - return lastActionOriginalMessage?.reportID; - } - return undefined; - }, [lastAction]); - const [lastActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(lastActionReportID ? String(lastActionReportID) : undefined)}`); const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.FROM)}`); const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(lastAction, CONST.REPORT.MOVE_TYPE.TO)}`); @@ -148,71 +59,34 @@ function OptionRowLHNData({ chatReport: chatReportForIOU, }); - const reportAttributesDerived = useMemo(() => { - if (!reportAttributes) { - return undefined; - } - return {[reportID]: reportAttributes} as ReportAttributesDerivedValue['reports']; - }, [reportID, reportAttributes]); - - const optionItem = useMemo( - () => - SidebarUtils.getOptionData({ - report: fullReport, - reportAttributes, - oneTransactionThreadReport, - reportNameValuePairs, - personalDetails, - policy, - parentReportAction, - conciergeReportID, - lastMessageTextFromReport, - invoiceReceiverPolicy, - card, - lastAction, - translate, - localeCompare, - isReportArchived, - lastActionReport, - movedFromReport, - movedToReport, - currentUserAccountID, - reportAttributesDerived, - policyTags, - currentUserLogin: login ?? '', - }), - // These subscriptions don't appear in getOptionData params but trigger recomputation - // when the underlying data changes (e.g. transaction amount update, IOU report actions change). - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - fullReport, - reportAttributes, - oneTransactionThreadReport, - reportNameValuePairs, - personalDetails, - policy, - parentReportAction, - conciergeReportID, - lastMessageTextFromReport, - invoiceReceiverPolicy, - card, - lastAction, - translate, - localeCompare, - isReportArchived, - lastActionReport, - movedFromReport, - movedToReport, - currentUserAccountID, - reportAttributesDerived, - policyTags, - login, - transaction, - iouReportReportActions, - lastReportActionTransaction, - reportActions, - ], - ); + // React Compiler auto-memoizes each expression in OptionRowLHN independently, + // so there is no need to stabilize the optionItem reference with deepEqual. + // When getOptionData returns a fresh object with the same content, the Compiler + // ensures that only expressions whose inputs actually changed recompute. + const optionItem = SidebarUtils.getOptionData({ + report: fullReport, + reportAttributes, + oneTransactionThreadReport, + reportNameValuePairs, + personalDetails, + policy, + parentReportAction, + conciergeReportID, + lastMessageTextFromReport, + invoiceReceiverPolicy, + card, + lastAction, + translate, + localeCompare, + isReportArchived, + lastActionReport, + movedFromReport, + movedToReport, + currentUserAccountID, + reportAttributesDerived, + policyTags, + currentUserLogin: login ?? '', + }); // For single-sender IOUs, trim to the sender's avatar to match the header. // The header uses reportPreviewSenderID as accountID for its primary avatar, @@ -233,7 +107,6 @@ function OptionRowLHNData({ isOptionFocused={isReportFocused} optionItem={finalOptionItem} report={fullReport} - hasDraftComment={hasDraftComment} conciergeReportID={conciergeReportID} /> ); diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index eeafd63ccd5e2..f2eb64cefe861 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -5,7 +5,8 @@ import type {ValueOf} from 'type-fest'; import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; import type CONST from '@src/CONST'; import type {OptionData} from '@src/libs/ReportUtils'; -import type {Onboarding, OnboardingPurpose, PersonalDetailsList, Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; +import type {Onboarding, OnboardingPurpose, PersonalDetailsList, Policy, Report, ReportAction, ReportNameValuePairs} from '@src/types/onyx'; +import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; type OptionMode = ValueOf; @@ -56,6 +57,9 @@ type OptionRowLHNDataProps = { /** The full data of the report */ fullReport: OnyxEntry; + /** The transaction thread report associated with the current report, if any */ + oneTransactionThreadReport: OnyxEntry; + /** Array of report name value pairs for this report */ reportNameValuePairs: OnyxEntry; @@ -65,18 +69,33 @@ type OptionRowLHNDataProps = { /** Invoice receiver policy */ invoiceReceiverPolicy?: OnyxEntry; + /** The action from the parent report */ + parentReportAction?: OnyxEntry; + + /** Whether a report contains a draft */ + hasDraftComment: boolean; + /** The reportID of the report */ reportID: string; /** Toggle between compact and default view */ viewMode?: OptionMode; + /** The last message text from the report */ + lastMessageTextFromReport?: string; + /** A function that is called when an option is selected. Selected option is passed as a param */ onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; /** Callback to execute when the OptionList lays out */ onLayout?: (event: LayoutChangeEvent) => void; + /** The report attributes for the report */ + reportAttributes: OnyxEntry; + + /** The derived report attributes for all reports */ + reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + /** Whether to show the educational tooltip for the GBR or RBR */ shouldShowRBRorGBRTooltip: boolean; @@ -92,6 +111,14 @@ type OptionRowLHNDataProps = { /** TestID of the row, indicating order */ testID: number; + /** Whether the report is archived */ + isReportArchived: boolean; + + /** The last action should be displayed */ + lastAction: ReportAction | undefined; + + lastActionReport: OnyxEntry | undefined; + /** The current user's account ID */ currentUserAccountID: number; };