Skip to content

Commit 5a8a078

Browse files
authored
Merge pull request Expensify#74071 from callstack-internal/optionlistcontext-provider-optimization
[POC] OptionListContextProvider optimization
2 parents 5920832 + c0fd0c9 commit 5a8a078

3 files changed

Lines changed: 368 additions & 78 deletions

File tree

src/hooks/useFilteredOptions.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import reportsSelector from '@selectors/Attributes';
2+
import {useEffect, useState} from 'react';
3+
import type {OnyxEntry} from 'react-native-onyx';
4+
import {createFilteredOptionList} from '@libs/OptionsListUtils';
5+
import type {OptionList} from '@libs/OptionsListUtils/types';
6+
import ONYXKEYS from '@src/ONYXKEYS';
7+
import type Beta from '@src/types/onyx/Beta';
8+
import useOnyx from './useOnyx';
9+
10+
type UseFilteredOptionsConfig = {
11+
/** Maximum number of recent reports to pre-filter and process (default: 500). */
12+
maxRecentReports?: number;
13+
/** Whether the hook should be enabled (default: true) */
14+
enabled?: boolean;
15+
/** Whether to include P2P personal details (default: true) */
16+
includeP2P?: boolean;
17+
/** Number of reports to load per batch when paginating (default: 100) */
18+
batchSize?: number;
19+
/** Whether to enable dynamic loading/pagination (default: true) */
20+
enablePagination?: boolean;
21+
/** Search term for filtering - when present, builds full report map for personal details (default: '') */
22+
searchTerm?: string;
23+
/** Beta features the user has access to */
24+
betas?: OnyxEntry<Beta[]>;
25+
};
26+
27+
type UseFilteredOptionsResult = {
28+
/** The computed options list (reports and personal details) */
29+
options: OptionList | null;
30+
/** Whether the options are currently being loaded (initial load) */
31+
isLoading: boolean;
32+
/** Function to load the next batch of reports */
33+
loadMore: () => void;
34+
/** Whether there are more reports available to load */
35+
hasMore: boolean;
36+
/** Whether currently loading the next batch */
37+
isLoadingMore: boolean;
38+
};
39+
40+
/**
41+
* Hook that provides options list for selection screens with optimized pre-filtering.
42+
*
43+
* Benefits over OptionListContextProvider:
44+
* - Only computes when screen is mounted and enabled
45+
* - No background recalculations when screen is not visible
46+
* - Smart pre-filtering for performance (top 500 recent reports)
47+
* - Recalculates only when dependencies change
48+
*
49+
* Pre-filtering strategy:
50+
* - Filters out null/undefined reports only
51+
* - Sorts by lastVisibleActionCreated (most recent first)
52+
* - Processes top N reports (default 500)
53+
* - Business logic filtering handled by shouldReportBeInOptionList
54+
*
55+
* Usage:
56+
* const {options, isLoading} = useFilteredOptions({
57+
* maxRecentReports: 500,
58+
* enabled: didScreenTransitionEnd,
59+
* betas,
60+
* });
61+
*
62+
* <SelectionList
63+
* sections={isLoading ? [] : sections}
64+
* showLoadingPlaceholder={isLoading}
65+
* />
66+
*/
67+
function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredOptionsResult {
68+
const {maxRecentReports = 500, enabled = true, includeP2P = true, batchSize = 100, searchTerm = '', betas} = config;
69+
70+
const [isLoadingMore, setIsLoadingMore] = useState(false);
71+
const [reportsLimit, setReportsLimit] = useState(maxRecentReports);
72+
73+
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true});
74+
const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});
75+
const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {
76+
canBeMissing: true,
77+
selector: reportsSelector,
78+
});
79+
80+
const totalReports = allReports ? Object.keys(allReports).length : 0;
81+
82+
const options: OptionList | null =
83+
enabled && allReports && allPersonalDetails
84+
? createFilteredOptionList(allPersonalDetails, allReports, reportAttributesDerived, {
85+
maxRecentReports: reportsLimit,
86+
includeP2P,
87+
searchTerm,
88+
betas,
89+
})
90+
: null;
91+
92+
// Reset loading state after options are computed
93+
useEffect(() => {
94+
if (!isLoadingMore || !options) {
95+
return;
96+
}
97+
setIsLoadingMore(false);
98+
}, [options, isLoadingMore]);
99+
100+
const loadMore = () => {
101+
if (!options || isLoadingMore) {
102+
return;
103+
}
104+
105+
const hasMoreToLoad = options.reports.length < totalReports;
106+
if (hasMoreToLoad) {
107+
setIsLoadingMore(true);
108+
setReportsLimit((prev) => prev + batchSize);
109+
}
110+
};
111+
112+
const hasMore = options ? options.reports.length < totalReports : false;
113+
114+
return {
115+
options,
116+
isLoading: !options,
117+
loadMore,
118+
hasMore,
119+
isLoadingMore,
120+
};
121+
}
122+
123+
export default useFilteredOptions;

src/libs/OptionsListUtils/index.ts

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
isWhisperAction,
8383
shouldReportActionBeVisible,
8484
} from '@libs/ReportActionsUtils';
85+
import {computeReportName} from '@libs/ReportNameUtils';
8586
import type {OptionData} from '@libs/ReportUtils';
8687
import {
8788
canUserPerformWriteAction,
@@ -104,7 +105,6 @@ import {
104105
getRejectedReportMessage,
105106
getReportActionActorAccountID,
106107
getReportLastMessage,
107-
getReportName,
108108
getReportNotificationPreference,
109109
getReportOrDraftReport,
110110
getReportPreviewMessage,
@@ -658,8 +658,8 @@ function getLastMessageTextForReport({
658658
: undefined;
659659
// For workspace chats, use the report title
660660
if (reportUtilsIsPolicyExpenseChat(report) && !isEmptyObject(iouReport)) {
661-
// eslint-disable-next-line @typescript-eslint/no-deprecated
662-
lastMessageTextFromReport = formatReportLastMessageText(getReportName(iouReport));
661+
const reportName = computeReportName(iouReport);
662+
lastMessageTextFromReport = formatReportLastMessageText(reportName);
663663
} else {
664664
const reportPreviewMessage = getReportPreviewMessage(
665665
!isEmptyObject(iouReport) ? iouReport : null,
@@ -913,11 +913,12 @@ function createOption(
913913
showPersonalDetails && personalDetail?.login
914914
? personalDetail.login
915915
: getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview}, !!result.private_isArchived, lastActorDetails);
916+
917+
const personalDetailsForCompute: PersonalDetailsList | undefined = personalDetails ?? undefined;
918+
const computedReportName = computeReportName(report, undefined, undefined, undefined, allReportNameValuePairs, personalDetailsForCompute, undefined);
916919
reportName = showPersonalDetails
917-
? getDisplayNameForParticipant({accountID: accountIDs.at(0), personalDetailsData: personalDetails ?? undefined, formatPhoneNumber: formatPhoneNumberPhoneUtils}) ||
918-
formatPhoneNumberPhoneUtils(personalDetail?.login ?? '')
919-
: // eslint-disable-next-line @typescript-eslint/no-deprecated
920-
getReportName(report);
920+
? getDisplayNameForParticipant({accountID: accountIDs.at(0), formatPhoneNumber: formatPhoneNumberPhoneUtils}) || formatPhoneNumberPhoneUtils(personalDetail?.login ?? '')
921+
: computedReportName;
921922
} else {
922923
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
923924
reportName =
@@ -934,7 +935,8 @@ function createOption(
934935
result.subtitle = subtitle;
935936

936937
// Set login and accountID only for single participant cases (used in SearchOption context)
937-
if (!hasMultipleParticipants && (!report || (report && !reportUtilsIsGroupChat(report) && !reportUtilsIsChatRoom(report)))) {
938+
// Also always set for personal details options (showPersonalDetails: true) to ensure search filtering works correctly
939+
if (showPersonalDetails || (!hasMultipleParticipants && (!report || (report && !reportUtilsIsGroupChat(report) && !reportUtilsIsChatRoom(report))))) {
938940
result.login = personalDetail?.login;
939941
result.accountID = Number(personalDetail?.accountID);
940942
}
@@ -965,8 +967,7 @@ function getReportOption(participant: Participant, reportAttributesDerived?: Rep
965967
// eslint-disable-next-line @typescript-eslint/no-deprecated
966968
option.alternateText = translateLocal('reportActionsView.yourSpace');
967969
} else if (option.isInvoiceRoom) {
968-
// eslint-disable-next-line @typescript-eslint/no-deprecated
969-
option.text = getReportName(report);
970+
option.text = computeReportName(report, undefined, undefined, undefined, allReportNameValuePairs, allPersonalDetails, undefined);
970971
// eslint-disable-next-line @typescript-eslint/no-deprecated
971972
option.alternateText = translateLocal('workspace.common.invoices');
972973
} else {
@@ -1015,8 +1016,7 @@ function getReportDisplayOption(report: OnyxEntry<Report>, unknownUserDetails: O
10151016
// eslint-disable-next-line @typescript-eslint/no-deprecated
10161017
option.alternateText = translateLocal('reportActionsView.yourSpace');
10171018
} else if (option.isInvoiceRoom) {
1018-
// eslint-disable-next-line @typescript-eslint/no-deprecated
1019-
option.text = getReportName(report);
1019+
option.text = computeReportName(report, undefined, undefined, undefined, allReportNameValuePairs, allPersonalDetails, undefined);
10201020
// eslint-disable-next-line @typescript-eslint/no-deprecated
10211021
option.alternateText = translateLocal('workspace.common.invoices');
10221022
} else if (unknownUserDetails) {
@@ -1236,6 +1236,119 @@ function createOptionList(personalDetails: OnyxEntry<PersonalDetailsList>, repor
12361236
};
12371237
}
12381238

1239+
/**
1240+
* Creates an optimized option list with smart pre-filtering.
1241+
*
1242+
* Performance optimization approach:
1243+
* 1. Pre-filters reports using shouldReportBeInOptionList with correct parameters (betas, etc.)
1244+
* 2. Sorts by lastVisibleActionCreated (most recent first)
1245+
* 3. Limits to top N reports
1246+
* 4. Processes only those N reports
1247+
*
1248+
* This avoids processing thousands of reports while ensuring correct filtering.
1249+
*
1250+
* Use this for screens that need recent reports (NewChatPage, WorkspaceInvitePage, etc.)
1251+
*/
1252+
function createFilteredOptionList(
1253+
personalDetails: OnyxEntry<PersonalDetailsList>,
1254+
reports: OnyxCollection<Report>,
1255+
reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined,
1256+
options: {
1257+
maxRecentReports?: number;
1258+
includeP2P?: boolean;
1259+
searchTerm?: string;
1260+
betas?: OnyxEntry<Beta[]>;
1261+
} = {},
1262+
) {
1263+
const {maxRecentReports = 500, includeP2P = true, searchTerm = ''} = options;
1264+
const reportMapForAccountIDs: Record<number, Report> = {};
1265+
1266+
// Step 1: Pre-filter reports to avoid processing thousands
1267+
// Only filter out null/undefined - let shouldReportBeInOptionList handle business logic
1268+
const reportsArray = Object.values(reports ?? {}).filter((report): report is Report => {
1269+
return !!report;
1270+
});
1271+
1272+
// Step 2: Sort by lastVisibleActionCreated (most recent first)
1273+
const sortedReports = reportsArray.sort((a, b) => {
1274+
const aTime = new Date(a.lastVisibleActionCreated ?? 0).getTime();
1275+
const bTime = new Date(b.lastVisibleActionCreated ?? 0).getTime();
1276+
return bTime - aTime;
1277+
});
1278+
1279+
// Step 3: Limit to top N reports
1280+
const limitedReports = sortedReports.slice(0, maxRecentReports);
1281+
1282+
// Step 4: If search term is present, build report map with ONLY 1:1 DM reports
1283+
// This allows personal details to have valid 1:1 DM reportIDs for proper avatar display
1284+
// Users without 1:1 DMs will have no report mapped, causing getIcons to fall back to personal avatar
1285+
if (searchTerm?.trim()) {
1286+
const allReportsArray = Object.values(reports ?? {});
1287+
1288+
// Add ONLY 1:1 DM reports (never add group/policy chats to maintain personal avatars)
1289+
for (const report of allReportsArray) {
1290+
if (!report) {
1291+
continue;
1292+
}
1293+
1294+
// Check if this is a 1:1 DM (not a group/policy/room chat)
1295+
const is1on1DM = reportUtilsIsOneOnOneChat(report);
1296+
1297+
if (is1on1DM) {
1298+
const accountIDs = getParticipantsAccountIDsForDisplay(report);
1299+
for (const accountID of accountIDs) {
1300+
// ALWAYS set 1:1 DMs - prioritize them over policy/group chats
1301+
// This ensures proper avatar display for personal details
1302+
reportMapForAccountIDs[accountID] = report;
1303+
}
1304+
}
1305+
}
1306+
}
1307+
1308+
// Step 5: Process the limited set of reports (performance optimization)
1309+
const reportOptions: Array<SearchOption<Report>> = [];
1310+
for (const report of limitedReports) {
1311+
const {reportMapEntry, reportOption} = processReport(report, personalDetails, reportAttributesDerived);
1312+
1313+
if (reportMapEntry) {
1314+
const [accountID, reportValue] = reportMapEntry;
1315+
1316+
// Preserve 1:1 DMs from Step 4 - don't overwrite them with non-1:1 reports
1317+
const existing = reportMapForAccountIDs[accountID];
1318+
const existingIs1on1 = existing && reportUtilsIsOneOnOneChat(existing);
1319+
const newIs1on1 = reportUtilsIsOneOnOneChat(reportValue);
1320+
1321+
// Only overwrite if: no existing, existing is not 1:1, or both are 1:1 (prefer newer)
1322+
const shouldOverwrite = !existing || !existingIs1on1 || newIs1on1;
1323+
1324+
if (shouldOverwrite) {
1325+
reportMapForAccountIDs[accountID] = reportValue;
1326+
}
1327+
}
1328+
1329+
if (reportOption) {
1330+
reportOptions.push(reportOption);
1331+
}
1332+
}
1333+
1334+
// Step 6: Process personal details (all of them - needed for search functionality)
1335+
const personalDetailsOptions = includeP2P
1336+
? Object.values(personalDetails ?? {}).map((personalDetail) => {
1337+
const accountID = personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID;
1338+
1339+
return {
1340+
item: personalDetail,
1341+
...createOption([accountID], personalDetails, reportMapForAccountIDs[accountID], {showPersonalDetails: true}, reportAttributesDerived),
1342+
};
1343+
})
1344+
: [];
1345+
1346+
return {
1347+
reports: reportOptions,
1348+
personalDetails: personalDetailsOptions as Array<SearchOption<PersonalDetails>>,
1349+
};
1350+
}
1351+
12391352
function createOptionFromReport(report: Report, personalDetails: OnyxEntry<PersonalDetailsList>, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], config?: PreviewConfig) {
12401353
const accountIDs = getParticipantsAccountIDsForDisplay(report);
12411354

@@ -2812,6 +2925,7 @@ export {
28122925
combineOrderingOfReportsAndPersonalDetails,
28132926
createOptionFromReport,
28142927
createOptionList,
2928+
createFilteredOptionList,
28152929
createOption,
28162930
filterAndOrderOptions,
28172931
filterOptions,

0 commit comments

Comments
 (0)