Skip to content
Closed
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
144 changes: 133 additions & 11 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
}

let allReportActions: OnyxCollection<ReportActions>;
Onyx.connect({

Check warning on line 90 in src/libs/ReportActionsUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand All @@ -99,7 +99,7 @@
});

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 102 in src/libs/ReportActionsUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -108,13 +108,13 @@
});

let isNetworkOffline = false;
Onyx.connect({

Check warning on line 111 in src/libs/ReportActionsUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NETWORK,
callback: (val) => (isNetworkOffline = val?.isOffline ?? false),
});

let deprecatedCurrentUserAccountID: number | undefined;
Onyx.connect({

Check warning on line 117 in src/libs/ReportActionsUtils.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
// When signed out, value is undefined
Expand Down Expand Up @@ -144,6 +144,18 @@
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<string, SortedReportActionsCacheEntry>();
const sortedReportActionsCacheDescending = new Map<string, SortedReportActionsCacheEntry>();
const shouldReportActionBeVisibleAsLastActionCache = new WeakMap<ReportAction, Map<boolean, boolean>>();

/*
* Url to the Xero non reimbursable expenses list
*/
Expand Down Expand Up @@ -608,6 +620,62 @@
);
}

/**
* 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<string, SortedReportActionsCacheEntry>): 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
Expand All @@ -618,9 +686,23 @@
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;
Expand All @@ -647,6 +729,11 @@
return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
});

evictOldestCacheEntries(cache);
cache.set(cacheKey, {
sortedActions,
lastAccessed: Date.now(),
});
return sortedActions;
}

Expand Down Expand Up @@ -1165,17 +1252,41 @@
return false;
}

const canWrite = canUserPerformWriteAction ?? false;
let actionCache = shouldReportActionBeVisibleAsLastActionCache.get(reportAction);
if (!actionCache) {
actionCache = new Map<boolean, boolean>();
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;
}

/**
Expand Down Expand Up @@ -1419,21 +1530,30 @@
* 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<ReportActions> | ReportAction[],
canUserPerformWriteAction?: boolean,
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]) => {
Expand Down Expand Up @@ -3927,6 +4047,8 @@
}

export {
setSortedReportActionsCacheMaxSize,
clearSortedReportActionsCache,
doesReportHaveVisibleActions,
extractLinksFromMessageHtml,
formatLastMessageText,
Expand Down
3 changes: 3 additions & 0 deletions src/libs/SidebarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
isTagModificationAction,
isTaskAction,
isTransactionThread,
setSortedReportActionsCacheMaxSize,
} from './ReportActionsUtils';
import type {OptionData} from './ReportUtils';
import {
Expand Down Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions tests/unit/MiddlewareTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ describe('Middleware', () => {
}),
}));

(global.fetch as jest.Mock).mockClear();
SequentialQueue.unpause();
await waitForBatchedUpdates();

Expand Down
18 changes: 17 additions & 1 deletion tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/ReportActionsUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ describe('ReportActionsUtils', () => {
});

describe('getSortedReportActions', () => {
beforeEach(() => {
ReportActionsUtils.clearSortedReportActionsCache();
});

const cases = [
[
[
Expand Down
Loading