From a421ca3ec33bb48dd9279f68510682ab662c0f19 Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Mon, 2 Mar 2026 20:40:20 +0000 Subject: [PATCH 1/9] Optimize getSections performance by eliminating redundant work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hoist shouldShowYear() out of .map() loop in getTaskSections to fix O(n²) behavior - Deduplicate buildLastExportedActionByReportIDMap() calls in getTransactionsSections and getReportSections by computing once and passing to shouldShowYear - Thread queryJSON parameter into getReportSections to avoid redundant navigation state traversal Co-authored-by: Aimane Chnaif --- src/libs/SearchUIUtils.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 063975daeaa5..ea7def931db6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -192,6 +192,7 @@ type GetReportSectionsParams = { bankAccountList: OnyxEntry; reportActions?: Record; allReportMetadata: OnyxCollection; + queryJSON?: SearchQueryJSON; }; type GetTransactionSectionsParams = { @@ -1392,6 +1393,7 @@ function buildLastExportedActionByReportIDMap(data: OnyxTypes.SearchResults['dat function shouldShowYear( data: TransactionListItemType[] | TransactionGroupListItemType[] | TaskListItemType[] | OnyxTypes.SearchResults['data'], checkOnlyReports = false, + precomputedLastExportedMap?: Map, ): ShouldShowYearResult { const result: ShouldShowYearResult = { shouldShowYearCreated: false, @@ -1449,7 +1451,7 @@ function shouldShowYear( return result; } - const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); + const lastExportedActionByReportID = precomputedLastExportedMap ?? buildLastExportedActionByReportIDMap(data); for (const key of Object.keys(data)) { if (!checkOnlyReports && isTransactionEntry(key)) { @@ -1670,7 +1672,8 @@ function getTransactionsSections({ cardFeeds, }: GetTransactionSectionsParams): [TransactionListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); - const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data); + const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); + const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data, false, lastExportedActionByReportID); const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = getWideAmountIndicators(data); // Pre-filter transaction keys to avoid repeated checks @@ -1684,8 +1687,6 @@ function getTransactionsSections({ const transactionsSections: TransactionListItemType[] = []; - const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); - // Use the provided queryJSON if available, otherwise fall back to getCurrentSearchQueryJSON() const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); @@ -1970,6 +1971,7 @@ function getTaskSections( formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], archivedReportsIDList?: ArchivedReportsIDSet, ): [TaskListItemType[], number] { + const {shouldShowYearCreated} = shouldShowYear(data); const tasks = Object.keys(data) .filter(isReportEntry) // Ensure that the reports that were passed are tasks, and not some other @@ -1987,7 +1989,6 @@ function getTaskSections( const report = getReportOrDraftReport(taskItem.reportID) ?? taskItem; const parentReport = getReportOrDraftReport(taskItem.parentReportID) ?? data[`${ONYXKEYS.COLLECTION.REPORT}${taskItem.parentReportID}`]; - const {shouldShowYearCreated} = shouldShowYear(data); const reportName = StringUtils.lineBreaksToSpaces(Parser.htmlToText(taskItem.reportName)); const description = StringUtils.lineBreaksToSpaces(Parser.htmlToText(taskItem.description)); @@ -2163,8 +2164,10 @@ function getReportSections({ bankAccountList, reportActions = {}, allReportMetadata, + queryJSON, }: GetReportSectionsParams): [TransactionGroupListItemType[], number] { const shouldShowMerchant = getShouldShowMerchant(data); + const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); const { shouldShowYearCreated: shouldShowYearCreatedTransaction, @@ -2172,14 +2175,14 @@ function getReportSections({ shouldShowYearApproved: shouldShowYearApprovedTransaction, shouldShowYearPosted: shouldShowYearPostedTransaction, shouldShowYearExported: shouldShowYearExportedTransaction, - } = shouldShowYear(data); + } = shouldShowYear(data, false, lastExportedActionByReportID); const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = getWideAmountIndicators(data); const {moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID} = createReportActionsLookupMaps(data); // Get violations - optimize by using a Map for faster lookups const allViolations = getViolations(data); - const queryJSON = getCurrentSearchQueryJSON(); + const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); const reportIDToTransactions: Record = {}; const {reportKeys, transactionKeys} = Object.keys(data).reduce( @@ -2200,9 +2203,7 @@ function getReportSections({ shouldShowYearSubmitted: shouldShowYearSubmittedReport, shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, - } = shouldShowYear(data, true); - - const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); + } = shouldShowYear(data, true, lastExportedActionByReportID); for (const key of orderedKeys) { if (isReportEntry(key) && (data[key].type === CONST.REPORT.TYPE.IOU || data[key].type === CONST.REPORT.TYPE.EXPENSE || data[key].type === CONST.REPORT.TYPE.INVOICE)) { @@ -2215,9 +2216,9 @@ function getReportSections({ let shouldShow = true; const isActionLoading = isActionLoadingSet?.has(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`); - if (queryJSON && !isActionLoading) { - if (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { - const status = queryJSON.status; + if (currentQueryJSON && !isActionLoading) { + if (currentQueryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE) { + const status = currentQueryJSON.status; if (Array.isArray(status)) { shouldShow = status.some((expenseStatus) => { @@ -2825,6 +2826,7 @@ function getSections({ bankAccountList, reportActions, allReportMetadata, + queryJSON, }); } From 56d5d14ee7ca67ecf402241276025b7c9c628019 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 3 Mar 2026 17:08:08 +0000 Subject: [PATCH 2/9] Eliminate O(R*N) bottleneck in getReportSections and remove redundant shouldShowYear pass - Pre-build transactionsByReportID Map during key-split reduce, replacing getTransactionsForReport O(N) scans per report with O(1) lookups - Pass precomputed transactions to getActions to avoid its internal getTransactionsForReport call for report entries - Compute report-level year flags inline during key-split reduce, eliminating the second shouldShowYear(data, true) full pass Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/libs/SearchUIUtils.ts | 45 ++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index ea7def931db6..45bd74c5a015 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1851,6 +1851,7 @@ function getActions( bankAccountList: OnyxEntry, reportMetadata: OnyxEntry, reportActions: OnyxTypes.ReportAction[] = [], + precomputedTransactionsForReport?: OnyxTypes.Transaction[], ): SearchTransactionAction[] { const isTransaction = isTransactionEntry(key); const report = getReportFromKey(data, key); @@ -1891,7 +1892,7 @@ function getActions( const allActions: SearchTransactionAction[] = []; let allReportTransactions: OnyxTypes.Transaction[]; if (isReportEntry(key)) { - allReportTransactions = getTransactionsForReport(data, report.reportID); + allReportTransactions = precomputedTransactionsForReport ?? getTransactionsForReport(data, report.reportID); } else { allReportTransactions = transaction ? [transaction] : []; } @@ -2185,12 +2186,43 @@ function getReportSections({ const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); const reportIDToTransactions: Record = {}; + // Build transactionsByReportID map and compute report-level year flags in a single pass, + // eliminating the separate shouldShowYear(data, true) call and getTransactionsForReport O(R*N) scans. + const transactionsByReportID = new Map(); + let shouldShowYearCreatedReport = false; + let shouldShowYearSubmittedReport = false; + let shouldShowYearApprovedReport = false; + let shouldShowYearExportedReport = false; + const {reportKeys, transactionKeys} = Object.keys(data).reduce( (acc, key) => { if (isReportEntry(key)) { acc.reportKeys.push(key); + const item = data[key]; + if (!shouldShowYearCreatedReport && item.created && DateUtils.doesDateBelongToAPastYear(item.created)) { + shouldShowYearCreatedReport = true; + } + if (!shouldShowYearSubmittedReport && item.submitted && DateUtils.doesDateBelongToAPastYear(item.submitted)) { + shouldShowYearSubmittedReport = true; + } + if (!shouldShowYearApprovedReport && item.approved && DateUtils.doesDateBelongToAPastYear(item.approved)) { + shouldShowYearApprovedReport = true; + } + const exportedAction = lastExportedActionByReportID.get(item.reportID); + if (!shouldShowYearExportedReport && exportedAction?.created && DateUtils.doesDateBelongToAPastYear(exportedAction.created)) { + shouldShowYearExportedReport = true; + } } else if (isTransactionEntry(key)) { acc.transactionKeys.push(key); + const transaction = data[key]; + if (transaction.reportID) { + const existing = transactionsByReportID.get(transaction.reportID); + if (existing) { + existing.push(transaction); + } else { + transactionsByReportID.set(transaction.reportID, [transaction]); + } + } } return acc; }, @@ -2198,12 +2230,6 @@ function getReportSections({ ); const orderedKeys: string[] = [...reportKeys, ...transactionKeys]; - const { - shouldShowYearCreated: shouldShowYearCreatedReport, - shouldShowYearSubmitted: shouldShowYearSubmittedReport, - shouldShowYearApproved: shouldShowYearApprovedReport, - shouldShowYearExported: shouldShowYearExportedReport, - } = shouldShowYear(data, true, lastExportedActionByReportID); for (const key of orderedKeys) { if (isReportEntry(key) && (data[key].type === CONST.REPORT.TYPE.IOU || data[key].type === CONST.REPORT.TYPE.EXPENSE || data[key].type === CONST.REPORT.TYPE.INVOICE)) { @@ -2234,7 +2260,8 @@ function getReportSections({ const reportPendingAction = reportItem?.pendingAction ?? reportItem?.pendingFields?.preview; const shouldShowBlankTo = !reportItem || isOpenExpenseReport(reportItem); const reportMetadata = allReportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportItem.reportID}`] ?? {}; - const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, actions); + const allReportTransactions = transactionsByReportID.get(reportItem.reportID) ?? []; + const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, currentAccountID, bankAccountList, reportMetadata, actions, allReportTransactions); const fromDetails = data.personalDetailsList?.[reportItem.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? @@ -2246,8 +2273,6 @@ function getReportSections({ const formattedTo = !shouldShowBlankTo ? formatPhoneNumber(getDisplayNameOrDefault(toDetails)) : ''; const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate}); - - const allReportTransactions = getTransactionsForReport(data, reportItem.reportID); const policyFromKey = getPolicyFromKey(data, reportItem); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportItem?.policyID ?? String(CONST.DEFAULT_NUMBER_ID)}`] ?? policyFromKey; From 6377d0ed99e20af67d1a9f7c56f30f153783d4a9 Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Tue, 3 Mar 2026 17:25:46 +0000 Subject: [PATCH 3/9] Extract getReportYearFlags to deduplicate report year flag logic Extracted the report date checking logic into a shared getReportYearFlags function used by both shouldShowYear and the getReportSections key-split reduce, eliminating code duplication. Co-authored-by: Aimane Chnaif --- src/libs/SearchUIUtils.ts | 77 ++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 45bd74c5a015..3fa29bbae823 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1384,6 +1384,24 @@ function buildLastExportedActionByReportIDMap(data: OnyxTypes.SearchResults['dat return lastExportedActionByReportID; } +/** + * @private + * Checks which year flags should be shown for a single report entry. + * Shared by shouldShowYear and getReportSections to avoid duplicating the report date checking logic. + */ +function getReportYearFlags( + report: OnyxTypes.Report, + lastExportedActionByReportID: Map, +): {shouldShowYearCreated: boolean; shouldShowYearSubmitted: boolean; shouldShowYearApproved: boolean; shouldShowYearExported: boolean} { + const exportedAction = lastExportedActionByReportID.get(report.reportID); + return { + shouldShowYearCreated: !!report.created && DateUtils.doesDateBelongToAPastYear(report.created), + shouldShowYearSubmitted: !!report.submitted && DateUtils.doesDateBelongToAPastYear(report.submitted), + shouldShowYearApproved: !!report.approved && DateUtils.doesDateBelongToAPastYear(report.approved), + shouldShowYearExported: !!exportedAction?.created && DateUtils.doesDateBelongToAPastYear(exportedAction.created), + }; +} + /** * Checks if the date of transactions or reports indicate the need to display the year because they are from a past year. * @param data - The search results data (array or object) @@ -1487,22 +1505,11 @@ function shouldShowYear( } } } else if (isReportEntry(key)) { - const item = data[key]; - - if (item.created && DateUtils.doesDateBelongToAPastYear(item.created)) { - result.shouldShowYearCreated = true; - } - if (item.submitted && DateUtils.doesDateBelongToAPastYear(item.submitted)) { - result.shouldShowYearSubmitted = true; - } - if (item.approved && DateUtils.doesDateBelongToAPastYear(item.approved)) { - result.shouldShowYearApproved = true; - } - - const exportedAction = lastExportedActionByReportID.get(item.reportID); - if (exportedAction?.created && DateUtils.doesDateBelongToAPastYear(exportedAction.created)) { - result.shouldShowYearExported = true; - } + const reportFlags = getReportYearFlags(data[key], lastExportedActionByReportID); + result.shouldShowYearCreated ||= reportFlags.shouldShowYearCreated; + result.shouldShowYearSubmitted ||= reportFlags.shouldShowYearSubmitted; + result.shouldShowYearApproved ||= reportFlags.shouldShowYearApproved; + result.shouldShowYearExported ||= reportFlags.shouldShowYearExported; } // Early exit if all flags are true @@ -2189,29 +2196,23 @@ function getReportSections({ // Build transactionsByReportID map and compute report-level year flags in a single pass, // eliminating the separate shouldShowYear(data, true) call and getTransactionsForReport O(R*N) scans. const transactionsByReportID = new Map(); - let shouldShowYearCreatedReport = false; - let shouldShowYearSubmittedReport = false; - let shouldShowYearApprovedReport = false; - let shouldShowYearExportedReport = false; + const reportYearFlags: ShouldShowYearResult = { + shouldShowYearCreated: false, + shouldShowYearSubmitted: false, + shouldShowYearApproved: false, + shouldShowYearPosted: false, + shouldShowYearExported: false, + }; const {reportKeys, transactionKeys} = Object.keys(data).reduce( (acc, key) => { if (isReportEntry(key)) { acc.reportKeys.push(key); - const item = data[key]; - if (!shouldShowYearCreatedReport && item.created && DateUtils.doesDateBelongToAPastYear(item.created)) { - shouldShowYearCreatedReport = true; - } - if (!shouldShowYearSubmittedReport && item.submitted && DateUtils.doesDateBelongToAPastYear(item.submitted)) { - shouldShowYearSubmittedReport = true; - } - if (!shouldShowYearApprovedReport && item.approved && DateUtils.doesDateBelongToAPastYear(item.approved)) { - shouldShowYearApprovedReport = true; - } - const exportedAction = lastExportedActionByReportID.get(item.reportID); - if (!shouldShowYearExportedReport && exportedAction?.created && DateUtils.doesDateBelongToAPastYear(exportedAction.created)) { - shouldShowYearExportedReport = true; - } + const reportFlags = getReportYearFlags(data[key], lastExportedActionByReportID); + reportYearFlags.shouldShowYearCreated ||= reportFlags.shouldShowYearCreated; + reportYearFlags.shouldShowYearSubmitted ||= reportFlags.shouldShowYearSubmitted; + reportYearFlags.shouldShowYearApproved ||= reportFlags.shouldShowYearApproved; + reportYearFlags.shouldShowYearExported ||= reportFlags.shouldShowYearExported; } else if (isTransactionEntry(key)) { acc.transactionKeys.push(key); const transaction = data[key]; @@ -2313,10 +2314,10 @@ function getReportSections({ transactions, shouldShowStatusAsPending, ...(reportPendingAction ? {pendingAction: reportPendingAction} : {}), - shouldShowYear: shouldShowYearCreatedReport, - shouldShowYearSubmitted: shouldShowYearSubmittedReport, - shouldShowYearApproved: shouldShowYearApprovedReport, - shouldShowYearExported: shouldShowYearExportedReport, + shouldShowYear: reportYearFlags.shouldShowYearCreated, + shouldShowYearSubmitted: reportYearFlags.shouldShowYearSubmitted, + shouldShowYearApproved: reportYearFlags.shouldShowYearApproved, + shouldShowYearExported: reportYearFlags.shouldShowYearExported, hasVisibleViolations: hasVisibleViolationsForReport, }; From ec7005b1e410a80a4617ca49eb2fa9a1688f47cf Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Tue, 3 Mar 2026 18:01:08 +0000 Subject: [PATCH 4/9] Consolidate pre-loop helpers into single-pass precomputeSearchMetadata Replace 7+ separate Object.keys(data) iterations with a single precomputeSearchMetadata function that iterates once and classifies each key by type (transaction, report, reportAction, violation), simultaneously computing: - shouldShowMerchant (from getShouldShowMerchant) - wide amount indicators (from getWideAmountIndicators) - shouldShowYear flags for both transaction and report modes - transactionKeys/reportKeys arrays - transactionsByReportID map - violations collection (inlined getViolations, now removed) - lastExportedActionByReportID map - report actions lookup maps (moneyRequest + hold) Hold-action matching uses a deferred post-loop phase for order-independence, avoiding reliance on alphabetical key ordering. Both getTransactionsSections and getReportSections now destructure from precomputeSearchMetadata instead of calling individual helpers. Co-authored-by: Aimane Chnaif --- src/libs/SearchUIUtils.ts | 319 +++++++++++++++++++++++++++++--------- 1 file changed, 246 insertions(+), 73 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7ede4869cdaa..5880eefeabbb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1523,22 +1523,6 @@ function shouldShowYear( return result; } -/** - * @private - * Extracts all transaction violations from the search data. - */ -function getViolations(data: OnyxTypes.SearchResults['data']): OnyxCollection { - const violations: OnyxCollection = {}; - - for (const key in data) { - if (isViolationEntry(key)) { - violations[key] = data[key]; - } - } - - return violations; -} - /** * @private * Generates a display name for IOU reports considering the personal details of the payer and the transaction details. @@ -1661,6 +1645,224 @@ function getToFieldValueForTransaction( return emptyPersonalDetails; } +/** + * Result of precomputeSearchMetadata — all metadata needed by getTransactionsSections and getReportSections, + * computed in a single pass over Object.keys(data). + */ +type PrecomputedSearchMetadata = { + transactionKeys: TransactionKey[]; + reportKeys: ReportKey[]; + transactionsByReportID: Map; + shouldShowMerchant: boolean; + shouldShowAmountInWideColumn: boolean; + shouldShowTaxAmountInWideColumn: boolean; + /** Year flags derived from transactions, report actions, and reports (transaction-level view) */ + yearFlags: ShouldShowYearResult; + /** Year flags derived only from report entries (report-level view) */ + reportYearFlags: ShouldShowYearResult; + lastExportedActionByReportID: Map; + violations: OnyxCollection; + moneyRequestReportActionsByTransactionID: Map; + holdReportActionsByTransactionID: Map; +}; + +/** + * @private + * Pre-computes all search metadata in a single pass over Object.keys(data), replacing 7+ separate iterations + * (getShouldShowMerchant, buildLastExportedActionByReportIDMap, shouldShowYear, getWideAmountIndicators, + * getViolations, createReportActionsLookupMaps, and key-split reduce) with one consolidated loop. + */ +function precomputeSearchMetadata(data: OnyxTypes.SearchResults['data']): PrecomputedSearchMetadata { + const transactionKeys: TransactionKey[] = []; + const reportKeys: ReportKey[] = []; + const transactionsByReportID = new Map(); + + // Display flags (from getShouldShowMerchant + getWideAmountIndicators) + let shouldShowMerchant = false; + let isAmountWide = false; + let isTaxAmountWide = false; + + // Year flags — transaction-level (from shouldShowYear with checkOnlyReports=false) + let yearCreated = false; + let yearSubmitted = false; + let yearApproved = false; + let yearPosted = false; + let yearExported = false; + + // Year flags — report-level (from shouldShowYear with checkOnlyReports=true) + let reportYearCreated = false; + let reportYearSubmitted = false; + let reportYearApproved = false; + let reportYearExported = false; + + // Lookup maps (from buildLastExportedActionByReportIDMap + createReportActionsLookupMaps) + const lastExportedActionByReportID = new Map(); + const moneyRequestReportActionsByTransactionID = new Map(); + const allHoldReportActions = new Map(); + const holdReportActionsByTransactionID = new Map(); + + // Violations (from getViolations) + const violations: OnyxCollection = {}; + + // Deferred hold-matching data (order-independent — matched after the loop) + const transactionsWithHold: Array<{transactionID: string; holdReportActionID: string}> = []; + + const currentYear = new Date().getFullYear(); + + for (const key of Object.keys(data)) { + if (isReportActionEntry(key)) { + // --- buildLastExportedActionByReportIDMap logic --- + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + const actions = data[key]; + let exportedAction: OnyxTypes.ReportAction | undefined; + let latestTime = -Infinity; + + for (const action of Object.values(actions)) { + // buildLastExportedActionByReportIDMap + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV || action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) { + const currentTime = new Date(action.created).getTime(); + if (currentTime > latestTime) { + latestTime = currentTime; + exportedAction = action; + } + } + + // createReportActionsLookupMaps — money request actions + if (isMoneyRequestAction(action)) { + const originalMessage = getOriginalMessage(action); + const transactionID = originalMessage?.IOUTransactionID; + if (transactionID) { + moneyRequestReportActionsByTransactionID.set(transactionID, action); + } + } else if (isHoldAction(action)) { + allHoldReportActions.set(action.reportActionID, action); + } + + // shouldShowYear — report action created dates (transaction-level only) + if (!yearCreated && DateUtils.doesDateBelongToAPastYear(action.created)) { + yearCreated = true; + } + } + + if (exportedAction) { + lastExportedActionByReportID.set(reportID, exportedAction); + } + } else if (isTransactionEntry(key)) { + const transaction = data[key]; + transactionKeys.push(key); + + // Build transactionsByReportID map + if (transaction.reportID) { + const existing = transactionsByReportID.get(transaction.reportID); + if (existing) { + existing.push(transaction); + } else { + transactionsByReportID.set(transaction.reportID, [transaction]); + } + } + + // getShouldShowMerchant + if (!shouldShowMerchant) { + const merchant = transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''); + shouldShowMerchant = !isInvalidMerchantValue(merchant) || isScanning(transaction); + } + + // getWideAmountIndicators + if (!isAmountWide) { + isAmountWide = isTransactionAmountTooLong(transaction); + } + if (!isTaxAmountWide) { + isTaxAmountWide = isTransactionTaxAmountTooLong(transaction); + } + + // shouldShowYear — transaction dates (transaction-level) + if (!yearCreated) { + const transactionCreated = getTransactionCreatedDate(transaction); + if (transactionCreated && DateUtils.doesDateBelongToAPastYear(transactionCreated)) { + yearCreated = true; + } + } + if (!yearSubmitted || !yearApproved) { + const report = data[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + if (!yearSubmitted && report?.submitted && DateUtils.doesDateBelongToAPastYear(report.submitted)) { + yearSubmitted = true; + } + if (!yearApproved && report?.approved && DateUtils.doesDateBelongToAPastYear(report.approved)) { + yearApproved = true; + } + } + if (!yearPosted && transaction?.posted) { + const postedYear = parseInt(transaction.posted.slice(0, 4), 10); + yearPosted = postedYear !== currentYear; + } + if (!yearExported) { + const txExportedAction = transaction.reportID ? lastExportedActionByReportID.get(transaction.reportID) : undefined; + if (txExportedAction?.created && DateUtils.doesDateBelongToAPastYear(txExportedAction.created)) { + yearExported = true; + } + } + + // Collect hold info for post-loop matching (order-independent) + const holdReportActionID = transaction?.comment?.hold; + if (holdReportActionID) { + transactionsWithHold.push({transactionID: transaction.transactionID, holdReportActionID}); + } + } else if (isReportEntry(key)) { + const report = data[key]; + reportKeys.push(key); + + // shouldShowYear — report dates (both transaction-level and report-level) + const reportFlags = getReportYearFlags(report, lastExportedActionByReportID); + yearCreated ||= reportFlags.shouldShowYearCreated; + yearSubmitted ||= reportFlags.shouldShowYearSubmitted; + yearApproved ||= reportFlags.shouldShowYearApproved; + yearExported ||= reportFlags.shouldShowYearExported; + + reportYearCreated ||= reportFlags.shouldShowYearCreated; + reportYearSubmitted ||= reportFlags.shouldShowYearSubmitted; + reportYearApproved ||= reportFlags.shouldShowYearApproved; + reportYearExported ||= reportFlags.shouldShowYearExported; + } else if (isViolationEntry(key)) { + violations[key] = data[key]; + } + } + + // Post-loop: match hold report actions with transactions (order-independent) + for (const {transactionID, holdReportActionID} of transactionsWithHold) { + const action = allHoldReportActions.get(holdReportActionID); + if (action) { + holdReportActionsByTransactionID.set(transactionID, action); + } + } + + return { + transactionKeys, + reportKeys, + transactionsByReportID, + shouldShowMerchant, + shouldShowAmountInWideColumn: isAmountWide, + shouldShowTaxAmountInWideColumn: isTaxAmountWide, + yearFlags: { + shouldShowYearCreated: yearCreated, + shouldShowYearSubmitted: yearSubmitted, + shouldShowYearApproved: yearApproved, + shouldShowYearPosted: yearPosted, + shouldShowYearExported: yearExported, + }, + reportYearFlags: { + shouldShowYearCreated: reportYearCreated, + shouldShowYearSubmitted: reportYearSubmitted, + shouldShowYearApproved: reportYearApproved, + shouldShowYearPosted: false, + shouldShowYearExported: reportYearExported, + }, + lastExportedActionByReportID, + violations, + moneyRequestReportActionsByTransactionID, + holdReportActionsByTransactionID, + }; +} + /** * @private * Organizes data into List Sections for display, for the TransactionListItemType of Search Results. @@ -1680,19 +1882,21 @@ function getTransactionsSections({ queryJSON, cardFeeds, }: GetTransactionSectionsParams): [TransactionListItemType[], number] { - const shouldShowMerchant = getShouldShowMerchant(data); - const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); - const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = shouldShowYear(data, false, lastExportedActionByReportID); - const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = getWideAmountIndicators(data); - - // Pre-filter transaction keys to avoid repeated checks - const transactionKeys = Object.keys(data).filter(isTransactionEntry); - // Get violations - optimize by using a Map for faster lookups - const allViolations = getViolations(data); + const metadata = precomputeSearchMetadata(data); + const { + shouldShowMerchant, + shouldShowAmountInWideColumn, + shouldShowTaxAmountInWideColumn, + transactionKeys, + lastExportedActionByReportID, + violations: allViolations, + moneyRequestReportActionsByTransactionID, + holdReportActionsByTransactionID, + } = metadata; + const {shouldShowYearCreated, shouldShowYearSubmitted, shouldShowYearApproved, shouldShowYearPosted, shouldShowYearExported} = metadata.yearFlags; - // Use Map for faster lookups of personal details and reportActions + // Use Map for faster lookups of personal details const personalDetailsMap = new Map(Object.entries(data.personalDetailsList ?? {})); - const {moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID} = createReportActionsLookupMaps(data); const transactionsSections: TransactionListItemType[] = []; @@ -2176,62 +2380,31 @@ function getReportSections({ allReportMetadata, queryJSON, }: GetReportSectionsParams): [TransactionGroupListItemType[], number] { - const shouldShowMerchant = getShouldShowMerchant(data); - const lastExportedActionByReportID = buildLastExportedActionByReportIDMap(data); - + const metadata = precomputeSearchMetadata(data); + const { + shouldShowMerchant, + shouldShowAmountInWideColumn, + shouldShowTaxAmountInWideColumn, + reportKeys, + transactionKeys, + transactionsByReportID, + lastExportedActionByReportID, + violations: allViolations, + moneyRequestReportActionsByTransactionID, + holdReportActionsByTransactionID, + reportYearFlags, + } = metadata; const { shouldShowYearCreated: shouldShowYearCreatedTransaction, shouldShowYearSubmitted: shouldShowYearSubmittedTransaction, shouldShowYearApproved: shouldShowYearApprovedTransaction, shouldShowYearPosted: shouldShowYearPostedTransaction, shouldShowYearExported: shouldShowYearExportedTransaction, - } = shouldShowYear(data, false, lastExportedActionByReportID); - const {shouldShowAmountInWideColumn, shouldShowTaxAmountInWideColumn} = getWideAmountIndicators(data); - const {moneyRequestReportActionsByTransactionID, holdReportActionsByTransactionID} = createReportActionsLookupMaps(data); - - // Get violations - optimize by using a Map for faster lookups - const allViolations = getViolations(data); + } = metadata.yearFlags; const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); const reportIDToTransactions: Record = {}; - // Build transactionsByReportID map and compute report-level year flags in a single pass, - // eliminating the separate shouldShowYear(data, true) call and getTransactionsForReport O(R*N) scans. - const transactionsByReportID = new Map(); - const reportYearFlags: ShouldShowYearResult = { - shouldShowYearCreated: false, - shouldShowYearSubmitted: false, - shouldShowYearApproved: false, - shouldShowYearPosted: false, - shouldShowYearExported: false, - }; - - const {reportKeys, transactionKeys} = Object.keys(data).reduce( - (acc, key) => { - if (isReportEntry(key)) { - acc.reportKeys.push(key); - const reportFlags = getReportYearFlags(data[key], lastExportedActionByReportID); - reportYearFlags.shouldShowYearCreated ||= reportFlags.shouldShowYearCreated; - reportYearFlags.shouldShowYearSubmitted ||= reportFlags.shouldShowYearSubmitted; - reportYearFlags.shouldShowYearApproved ||= reportFlags.shouldShowYearApproved; - reportYearFlags.shouldShowYearExported ||= reportFlags.shouldShowYearExported; - } else if (isTransactionEntry(key)) { - acc.transactionKeys.push(key); - const transaction = data[key]; - if (transaction.reportID) { - const existing = transactionsByReportID.get(transaction.reportID); - if (existing) { - existing.push(transaction); - } else { - transactionsByReportID.set(transaction.reportID, [transaction]); - } - } - } - return acc; - }, - {reportKeys: [] as string[], transactionKeys: [] as string[]}, - ); - const orderedKeys: string[] = [...reportKeys, ...transactionKeys]; for (const key of orderedKeys) { From 8b2e132a171199fdfeb3a3380eee24934ad4b051 Mon Sep 17 00:00:00 2001 From: "Carlos Martins (via MelvinBot)" Date: Tue, 3 Mar 2026 20:32:33 +0000 Subject: [PATCH 5/9] Fix: Restore spend breakdown and isAllScanning fields in getReportSections The optimization refactoring accidentally removed the getMoneyRequestSpendBreakdown call and the totalDisplaySpend, nonReimbursableSpend, reimbursableSpend, and isAllScanning fields from report section objects. This restores them. Co-authored-by: Carlos Martins --- src/libs/SearchUIUtils.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 50d64e90c0dc..6af8beb30348 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -118,6 +118,7 @@ import { findSelfDMReportID, generateReportID, getIcons, + getMoneyRequestSpendBreakdown, getPersonalDetailsForAccountID, getPolicyName, getReportName, @@ -2299,6 +2300,7 @@ function getReportSections({ allReportTransactions, ); + const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = getMoneyRequestSpendBreakdown(reportItem); reportIDToTransactions[reportKey] = { ...reportItem, action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, @@ -2319,6 +2321,10 @@ function getReportSections({ shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, hasVisibleViolations: hasVisibleViolationsForReport, + totalDisplaySpend, + nonReimbursableSpend, + reimbursableSpend, + isAllScanning: false, }; if (isIOUReport) { @@ -2377,12 +2383,12 @@ function getReportSections({ isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn, category: isIOUReport ? '' : transactionItem?.category, }; - if (reportIDToTransactions[reportKey]?.transactions) { - reportIDToTransactions[reportKey].transactions.push(transaction); - reportIDToTransactions[reportKey].from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; - } else if (reportIDToTransactions[reportKey]) { - reportIDToTransactions[reportKey].transactions = [transaction]; - reportIDToTransactions[reportKey].from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; + if (reportIDToTransactions[reportKey]) { + const reportSection = reportIDToTransactions[reportKey]; + const hadTransactions = reportSection.transactions.length > 0; + reportSection.transactions.push(transaction); + reportSection.from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; + reportSection.isAllScanning = hadTransactions ? !!reportSection.isAllScanning && isScanning(transaction) : isScanning(transaction); } } } From ba30b5321760f013d9133d4c076e8c7648a5614d Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Tue, 3 Mar 2026 20:34:05 +0000 Subject: [PATCH 6/9] Restore totalDisplaySpend, nonReimbursableSpend, reimbursableSpend, and isAllScanning fields These fields were inadvertently removed in an earlier optimization commit but are actively consumed by ExpenseReportListItemRow for rendering expense report totals and scanning state in search results. Co-authored-by: Aimane Chnaif --- src/libs/SearchUIUtils.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 50d64e90c0dc..511f6bb80bf1 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -118,6 +118,7 @@ import { findSelfDMReportID, generateReportID, getIcons, + getMoneyRequestSpendBreakdown, getPersonalDetailsForAccountID, getPolicyName, getReportName, @@ -2299,6 +2300,8 @@ function getReportSections({ allReportTransactions, ); + const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = getMoneyRequestSpendBreakdown(reportItem); + reportIDToTransactions[reportKey] = { ...reportItem, action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, @@ -2319,6 +2322,10 @@ function getReportSections({ shouldShowYearApproved: shouldShowYearApprovedReport, shouldShowYearExported: shouldShowYearExportedReport, hasVisibleViolations: hasVisibleViolationsForReport, + totalDisplaySpend, + nonReimbursableSpend, + reimbursableSpend, + isAllScanning: false, }; if (isIOUReport) { @@ -2377,12 +2384,12 @@ function getReportSections({ isTaxAmountColumnWide: shouldShowTaxAmountInWideColumn, category: isIOUReport ? '' : transactionItem?.category, }; - if (reportIDToTransactions[reportKey]?.transactions) { - reportIDToTransactions[reportKey].transactions.push(transaction); - reportIDToTransactions[reportKey].from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; - } else if (reportIDToTransactions[reportKey]) { - reportIDToTransactions[reportKey].transactions = [transaction]; - reportIDToTransactions[reportKey].from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; + if (reportIDToTransactions[reportKey]) { + const reportSection = reportIDToTransactions[reportKey]; + const hadTransactions = reportSection.transactions.length > 0; + reportSection.transactions.push(transaction); + reportSection.from = data?.personalDetailsList?.[data?.[reportKey as ReportKey]?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; + reportSection.isAllScanning = hadTransactions ? !!reportSection.isAllScanning && isScanning(transaction) : isScanning(transaction); } } } From d53ba2382d17810f3ad35c59d6694c8e3658c8c1 Mon Sep 17 00:00:00 2001 From: "Carlos Martins (via MelvinBot)" Date: Tue, 3 Mar 2026 20:55:49 +0000 Subject: [PATCH 7/9] Fix: Add missing currentUserLogin to callGetOptionData test helper After merging main (which made currentUserLogin required in getOptionData via PR #83550), the test helper was missing this property, causing a typecheck failure. Co-authored-by: Carlos Martins --- tests/unit/SidebarUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index c3a76d2ac3fd..70ae80cb9a3e 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -3355,6 +3355,7 @@ describe('SidebarUtils', () => { lastActionReport: undefined, isReportArchived: undefined, currentUserAccountID: 0, + currentUserLogin: '', reportAttributesDerived: undefined, }); } From 98e639f2f03667cce7df7563f53e4073d8cde48f Mon Sep 17 00:00:00 2001 From: "Carlos Martins (via MelvinBot)" Date: Tue, 3 Mar 2026 21:48:40 +0000 Subject: [PATCH 8/9] Trigger CI re-run: flaky test failures Co-authored-by: Carlos Martins From 6c4a0e0b8758691f0664f106bc7c38d3380aca8d Mon Sep 17 00:00:00 2001 From: "Carlos Martins (via MelvinBot)" Date: Thu, 5 Mar 2026 17:27:19 +0000 Subject: [PATCH 9/9] Update comment per review feedback Co-authored-by: Carlos Martins --- src/libs/SearchUIUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0c82b0ee7882..d6d8e8bcbec5 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2194,8 +2194,7 @@ function getReportSections({ const currentQueryJSON = queryJSON ?? getCurrentSearchQueryJSON(); const reportIDToTransactions: Record = {}; - // Build transactionsByReportID map and compute report-level year flags in a single pass, - // eliminating the separate shouldShowYear(data, true) call and getTransactionsForReport O(R*N) scans. + // Build transactionsByReportID map and compute report-level year flags in a single pass for performance reasons. const transactionsByReportID = new Map(); let shouldShowYearCreatedReport = false; let shouldShowYearSubmittedReport = false;