diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 530d57790267..aa353d3ccf0f 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -187,7 +187,12 @@ function SearchAutocompleteList({ const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const {options: listOptions, isLoading: isLoadingOptions} = useFilteredOptions({enabled: true, isSearching: !!autocompleteQueryValue.trim(), betas: betas ?? []}); + const {options: listOptions, isLoading: isLoadingOptions} = useFilteredOptions({ + enabled: true, + isSearching: !!autocompleteQueryValue.trim(), + deferContactsUntilSearch: true, + betas: betas ?? [], + }); const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata); diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 53cf0487baa9..d9ba02a25cbe 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -26,6 +26,12 @@ type UseFilteredOptionsConfig = { enablePagination?: boolean; /** Whether search mode is active - when true, builds full report map for personal details (default: false) */ isSearching?: boolean; + /** + * When true, contacts (personal details) are only built while searching. Use on screens whose + * idle/empty state does not show standalone contacts (e.g. the SearchRouter) to avoid building + * an option per contact on open. Leave false for contact pickers (default: false). + */ + deferContactsUntilSearch?: boolean; /** Beta features the user has access to */ betas?: OnyxEntry; }; @@ -71,7 +77,7 @@ type UseFilteredOptionsResult = { * /> */ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredOptionsResult { - const {maxRecentReports = 500, enabled = true, includeP2P = true, batchSize = 100, isSearching = false, betas} = config; + const {maxRecentReports = 500, enabled = true, includeP2P = true, batchSize = 100, isSearching = false, deferContactsUntilSearch = false, betas} = config; const [reportsLimit, setReportsLimit] = useState(maxRecentReports); @@ -99,6 +105,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO maxRecentReports: reportsLimit, includeP2P, isSearching, + deferContactsUntilSearch, betas, }, undefined, @@ -106,7 +113,20 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO isTrackIntentUser, ) : null, - [enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas, isTrackIntentUser], + [ + enabled, + allReports, + allPersonalDetails, + reportAttributesDerived, + privateIsArchivedMap, + allPolicies, + reportsLimit, + includeP2P, + isSearching, + deferContactsUntilSearch, + betas, + isTrackIntentUser, + ], ); // When isSearching is set to true, the createFilteredOptionList returns all reports diff --git a/src/hooks/usePrivateIsArchivedMap.ts b/src/hooks/usePrivateIsArchivedMap.ts index 393e2cc4174b..47424991523b 100644 --- a/src/hooks/usePrivateIsArchivedMap.ts +++ b/src/hooks/usePrivateIsArchivedMap.ts @@ -1,14 +1,22 @@ import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportNameValuePairs} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; import useOnyx from './useOnyx'; type PrivateIsArchivedMap = Record; -/** - * Hook that returns a map of report IDs to their private_isArchived values - */ -function usePrivateIsArchivedMap(): PrivateIsArchivedMap { - const [allReportNVP] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); +// Single-entry cache so the derived map keeps a stable reference across renders and remounts +// while the underlying REPORT_NAME_VALUE_PAIRS collection is referentially unchanged. +let cachedSource: OnyxCollection; +let cachedMap: PrivateIsArchivedMap = {}; +let hasCached = false; + +function buildPrivateIsArchivedMap(allReportNVP: OnyxCollection): PrivateIsArchivedMap { + if (hasCached && allReportNVP === cachedSource) { + return cachedMap; + } const map: PrivateIsArchivedMap = {}; if (allReportNVP) { @@ -16,8 +24,20 @@ function usePrivateIsArchivedMap(): PrivateIsArchivedMap { map[key] = !!value?.private_isArchived; } } + + cachedSource = allReportNVP; + cachedMap = map; + hasCached = true; return map; } +/** + * Hook that returns a map of report IDs to their private_isArchived values + */ +function usePrivateIsArchivedMap(): PrivateIsArchivedMap { + const [allReportNVP] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + return buildPrivateIsArchivedMap(allReportNVP); +} + export default usePrivateIsArchivedMap; export type {PrivateIsArchivedMap}; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 95204ae05c6d..6d0200a5e673 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -169,6 +169,7 @@ import {getDescription, getAmount as getTransactionAmount, getCurrency as getTra import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Beta, @@ -1596,6 +1597,21 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc * * Use this for screens that need recent reports (NewChatPage, WorkspaceInvitePage, etc.) */ +type FilteredOptionListResult = { + reports: Array>; + personalDetails: Array>; +}; + +// Shared stable default so an omitted `visibleReportActionsData` keeps a constant reference across calls, +// which the single-entry cache below relies on for hits. +const EMPTY_VISIBLE_REPORT_ACTIONS: VisibleReportActionsDerivedValue = {}; + +// Single-entry cache so reopening a selection screen (which remounts and recomputes) +// reuses the previous result while the underlying Onyx inputs are referentially unchanged. +// `betas` is intentionally excluded from the key because it does not affect the output here. +let filteredOptionListCacheKey: unknown[] | null = null; +let filteredOptionListCacheValue: FilteredOptionListResult | null = null; + function createFilteredOptionList( personalDetails: OnyxEntry, reports: OnyxCollection, @@ -1607,12 +1623,45 @@ function createFilteredOptionList( includeP2P?: boolean; isSearching?: boolean; betas?: OnyxEntry; + /** + * When true, personal details (contacts) are only built while searching (`isSearching`). + * For screens whose idle/empty state shows no standalone contacts (e.g. the SearchRouter), + * this skips building an option for every contact on open. Screens that show contacts at + * empty state (contact pickers) must leave this false. + */ + deferContactsUntilSearch?: boolean; } = {}, policyTags?: OnyxCollection, - visibleReportActionsData: VisibleReportActionsDerivedValue = {}, + visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, isTrackIntentUser?: boolean, -) { - const {maxRecentReports = 500, includeP2P = true, isSearching = false} = options; +): FilteredOptionListResult { + const {maxRecentReports = 500, includeP2P = true, isSearching = false, deferContactsUntilSearch = false} = options; + + // Contacts are expensive to build on large accounts (one option per personal detail). When a screen + // opts into deferral and is not actively searching, skip building them entirely; the empty state + // does not display standalone contacts, and typing flips `isSearching` which rebuilds the full set. + const shouldBuildContacts = includeP2P && !(deferContactsUntilSearch && !isSearching); + + const cacheKey = [ + personalDetails, + reports, + reportAttributesDerived, + privateIsArchivedMap, + policiesCollection, + maxRecentReports, + includeP2P, + isSearching, + deferContactsUntilSearch, + policyTags, + visibleReportActionsData, + isTrackIntentUser, + // Option building translates strings imperatively (translateLocal), so the active locale is part of the output. + IntlStore.getCurrentLocale(), + ]; + if (filteredOptionListCacheValue && filteredOptionListCacheKey?.length === cacheKey.length && cacheKey.every((value, index) => value === filteredOptionListCacheKey?.[index])) { + return filteredOptionListCacheValue; + } + const reportMapForAccountIDs: Record = {}; // Step 1: Pre-filter reports to avoid processing thousands @@ -1688,8 +1737,8 @@ function createFilteredOptionList( } } - // Step 5: Process personal details (all of them - needed for search functionality) - const personalDetailsOptions = includeP2P + // Step 5: Process personal details (all of them when built - needed for search functionality) + const personalDetailsOptions = shouldBuildContacts ? Object.values(personalDetails ?? {}).map((personalDetail) => { const accountID = personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID; @@ -1714,10 +1763,15 @@ function createFilteredOptionList( }) : []; - return { + const result: FilteredOptionListResult = { reports: reportOptions, personalDetails: personalDetailsOptions as Array>, }; + + filteredOptionListCacheKey = cacheKey; + filteredOptionListCacheValue = result; + + return result; } function createOptionFromReport(