diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fcd2631b1c87..cdd3e4616aa2 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -144,6 +144,18 @@ getEnvironmentURL().then((url: string) => (environmentURL = url)); let oldDotEnvironmentURL: string; getOldDotEnvironmentURL().then((url: string) => (oldDotEnvironmentURL = url)); +type SortedReportActionsCacheEntry = { + sortedActions: ReportAction[]; + lastAccessed: number; +}; + +const DEFAULT_SORTED_REPORT_ACTIONS_CACHE_MAX_SIZE = 1000; +let sortedReportActionsCacheMaxSize = DEFAULT_SORTED_REPORT_ACTIONS_CACHE_MAX_SIZE; + +const sortedReportActionsCacheAscending = new Map(); +const sortedReportActionsCacheDescending = new Map(); +const shouldReportActionBeVisibleAsLastActionCache = new WeakMap>(); + /* * Url to the Xero non reimbursable expenses list */ @@ -608,6 +620,62 @@ function isTransactionThread(parentReportAction: OnyxInputOrEntry) ); } +/** + * Clears the sorted report actions caches. Exposed for tests to avoid cross-test cache pollution + * when different tests use the same reportActionIDs with different metadata (created, actionName). + */ +function clearSortedReportActionsCache(): void { + sortedReportActionsCacheAscending.clear(); + sortedReportActionsCacheDescending.clear(); +} + +/** + * Generates a cache key based on reportActionIDs (sorted for consistency). + * This allows us to cache sorted results even when we receive new array references. + */ +function getCacheKeyForReportActions(reportActions: ReportAction[]): string { + return reportActions + .map((action) => action.reportActionID) + .sort() + .join(','); +} + +/** + * Ensures cache doesn't exceed max size by removing least recently used entries (LRU). + */ +function evictOldestCacheEntries(cache: Map): void { + if (cache.size <= sortedReportActionsCacheMaxSize) { + return; + } + + const entries = Array.from(cache.entries()); + entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + const entriesToRemove = cache.size - sortedReportActionsCacheMaxSize; + for (let i = 0; i < entriesToRemove; i++) { + const entry = entries.at(i); + if (entry !== undefined) { + cache.delete(entry[0]); + } + } +} + +/** + * Adjust the maximum size for the sorted report actions cache. + * Used by callers that know the current number of reports (e.g. LHN), + * while still enforcing a global hard cap for safety. + */ +function setSortedReportActionsCacheMaxSize(newSize: number): void { + if (!Number.isFinite(newSize) || newSize <= 0) { + sortedReportActionsCacheMaxSize = DEFAULT_SORTED_REPORT_ACTIONS_CACHE_MAX_SIZE; + } else { + sortedReportActionsCacheMaxSize = newSize; + } + + evictOldestCacheEntries(sortedReportActionsCacheAscending); + evictOldestCacheEntries(sortedReportActionsCacheDescending); +} + /** * Sort an array of reportActions by their created timestamp first, and reportActionID second * This gives us a stable order even in the case of multiple reportActions created on the same millisecond @@ -618,9 +686,23 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`); } + const filteredActions = reportActions.filter(Boolean); + if (filteredActions.length === 0) { + return []; + } + + const cache = shouldSortInDescendingOrder ? sortedReportActionsCacheDescending : sortedReportActionsCacheAscending; + const cacheKey = getCacheKeyForReportActions(filteredActions); + const cachedEntry = cache.get(cacheKey); + + if (cachedEntry) { + cachedEntry.lastAccessed = Date.now(); + return [...cachedEntry.sortedActions]; + } + const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1; - const sortedActions = reportActions?.filter(Boolean).sort((first, second) => { + const sortedActions = [...filteredActions].sort((first, second) => { // First sort by action type, ensuring that `CREATED` actions always come first if they have the same or even a later timestamp as another action type if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) { return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier; @@ -647,6 +729,11 @@ function getSortedReportActions(reportActions: ReportAction[] | null, shouldSort return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier; }); + evictOldestCacheEntries(cache); + cache.set(cacheKey, { + sortedActions, + lastAccessed: Date.now(), + }); return sortedActions; } @@ -1165,17 +1252,41 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry< return false; } + const canWrite = canUserPerformWriteAction ?? false; + let actionCache = shouldReportActionBeVisibleAsLastActionCache.get(reportAction); + if (!actionCache) { + actionCache = new Map(); + shouldReportActionBeVisibleAsLastActionCache.set(reportAction, actionCache); + } + + const cached = actionCache.get(canWrite); + if (cached !== undefined) { + return cached; + } + if (Object.keys(reportAction.errors ?? {}).length > 0) { + actionCache.set(canWrite, false); return false; } - // If a whisper action is the REPORT_PREVIEW action, we are displaying it. - // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable. - return ( - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction) && - (!(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) || isActionableMentionWhisper(reportAction)) && - !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction) && !isPendingHide(reportAction)) - ); + if (!shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction)) { + actionCache.set(canWrite, false); + return false; + } + + const isWhisper = isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction); + if (isWhisper && !isActionableMentionWhisper(reportAction)) { + actionCache.set(canWrite, false); + return false; + } + + if (isDeletedAction(reportAction) && !isDeletedParentAction(reportAction) && !isPendingHide(reportAction)) { + actionCache.set(canWrite, false); + return false; + } + + actionCache.set(canWrite, true); + return true; } /** @@ -1419,6 +1530,12 @@ function withDEWRoutedActionsObject(reportActions: OnyxEntry): On * The report actions need to be sorted by created timestamp first, and reportActionID second * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp). * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY. + * + * @param reportActions - The report actions to process (either OnyxEntry or array) + * @param canUserPerformWriteAction - Whether the user can perform write actions + * @param shouldIncludeInvisibleActions - Whether to include invisible actions + * @param preFilteredActions - Optional pre-filtered actions array to avoid redundant filtering + * @returns Sorted array of report actions ready for display */ function getSortedReportActionsForDisplay( reportActions: OnyxEntry | ReportAction[], @@ -1426,14 +1543,17 @@ function getSortedReportActionsForDisplay( shouldIncludeInvisibleActions = false, visibleReportActionsData?: VisibleReportActionsDerivedValue, reportID?: string, + preFilteredActions?: ReportAction[], ): ReportAction[] { - let filteredReportActions: ReportAction[] = []; if (!reportActions) { return []; } - if (shouldIncludeInvisibleActions) { - filteredReportActions = Object.values(reportActions).filter(Boolean); + let filteredReportActions: ReportAction[]; + if (preFilteredActions) { + filteredReportActions = preFilteredActions; + } else if (shouldIncludeInvisibleActions) { + filteredReportActions = Array.isArray(reportActions) ? reportActions : Object.values(reportActions).filter(Boolean); } else { filteredReportActions = Object.entries(reportActions) .filter(([collectionKey, reportAction]) => { @@ -3927,6 +4047,8 @@ function hasReasoning(action: OnyxInputOrEntry): boolean { } export { + setSortedReportActionsCacheMaxSize, + clearSortedReportActionsCache, doesReportHaveVisibleActions, extractLinksFromMessageHtml, formatLastMessageText, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2081c685aa56..07238e058e95 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -109,6 +109,7 @@ import { isTagModificationAction, isTaskAction, isTransactionThread, + setSortedReportActionsCacheMaxSize, } from './ReportActionsUtils'; import type {OptionData} from './ReportUtils'; import { @@ -273,6 +274,8 @@ function getReportsToDisplayInLHN( ) { const isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; const allReportsDictValues = reports ?? {}; + + setSortedReportActionsCacheMaxSize(Object.keys(allReportsDictValues).length); const reportsToDisplay: ReportsToDisplayInLHN = {}; for (const [reportID, report] of Object.entries(allReportsDictValues)) { diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index 2d449d11cfa3..f0a63b130dc5 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -404,6 +404,7 @@ describe('Middleware', () => { }), })); + (global.fetch as jest.Mock).mockClear(); SequentialQueue.unpause(); await waitForBatchedUpdates(); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 725011d14142..f076a3b2a424 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -41,7 +41,7 @@ import { sortAlphabetically, } from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; -import {getChangedApproverActionMessage, getDynamicExternalWorkflowRoutedMessage} from '@libs/ReportActionsUtils'; +import {clearSortedReportActionsCache, getChangedApproverActionMessage, getDynamicExternalWorkflowRoutedMessage} from '@libs/ReportActionsUtils'; import { canCreateTaskInReport, canUserPerformWriteAction, @@ -3188,6 +3188,13 @@ describe('OptionsListUtils', () => { }); describe('getLastMessageTextForReport', () => { + beforeEach(async () => { + clearSortedReportActionsCache(); + await act(async () => { + await Onyx.clear(); + }); + }); + describe('getReportPreviewMessage', () => { it('should format report preview message correctly for non-policy expense chat with IOU action', async () => { const iouReport: Report = { @@ -3262,6 +3269,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [movedTransactionAction.reportActionID]: movedTransactionAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3286,6 +3294,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [submittedAction.reportActionID]: submittedAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3312,6 +3321,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [approvedAction.reportActionID]: approvedAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3338,6 +3348,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [forwardedAction.reportActionID]: forwardedAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3361,6 +3372,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [corporateForceUpgradeAction.reportActionID]: corporateForceUpgradeAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3383,6 +3395,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [takeControlAction.reportActionID]: takeControlAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3403,6 +3416,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [rerouteAction.reportActionID]: rerouteAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3423,6 +3437,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [movedAction.reportActionID]: movedAction, }); + await waitForBatchedUpdates(); const lastMessage = getLastMessageTextForReport({ translate: translateLocal, report, @@ -3445,6 +3460,7 @@ describe('OptionsListUtils', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { [action.reportActionID]: action, }); + await waitForBatchedUpdates(); // When getting the last message text for the report const lastMessage = getLastMessageTextForReport({ diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 35ce577d3bb7..a709479fc2b3 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -62,6 +62,10 @@ describe('ReportActionsUtils', () => { }); describe('getSortedReportActions', () => { + beforeEach(() => { + ReportActionsUtils.clearSortedReportActionsCache(); + }); + const cases = [ [ [