From c8f5b01ecadd61db7da348fd637acb888e4cc568 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 6 Jul 2026 12:57:35 +0200 Subject: [PATCH 1/5] Speed up SearchRouter open on large accounts Two changes to the SearchRouter empty-query open path (ManualOpenSearchRouter / SearchRouter.ListRender span): 1. Cache createFilteredOptionList results in a module-level single-entry cache keyed on input references, so reopening the router reuses the previous result while the underlying Onyx data is unchanged. Two inputs are reference-stabilized to make hits possible: usePrivateIsArchivedMap (module cache keyed on the REPORT_NAME_VALUE_PAIRS collection reference) and the visibleReportActionsData default (shared constant instead of a fresh object per call). 2. New deferContactsUntilSearch option (enabled only for the SearchRouter's SearchAutocompleteList) skips building an option per personal detail while the query is empty; the empty state shows no standalone contacts, and typing flips isSearching which builds the full set. Other useFilteredOptions consumers are unaffected (default false). Measured on web dev with 19k personal details / 588 reports: warm reopen median 199.5ms -> ~105ms, cold open 211ms -> ~150ms. --- .../Search/SearchAutocompleteList.tsx | 7 ++- src/hooks/useFilteredOptions.ts | 24 ++++++- src/hooks/usePrivateIsArchivedMap.ts | 29 +++++++-- src/libs/OptionsListUtils/index.ts | 63 +++++++++++++++++-- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index f0dcbc936708..a2099be002aa 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -180,7 +180,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 d7c5411936a0..b3be888bdc0c 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -22,6 +22,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; }; @@ -67,7 +73,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); @@ -95,6 +101,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO maxRecentReports: reportsLimit, includeP2P, isSearching, + deferContactsUntilSearch, betas, }, undefined, @@ -102,7 +109,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 0ab1b6cf8c82..21b31971f564 100644 --- a/src/hooks/usePrivateIsArchivedMap.ts +++ b/src/hooks/usePrivateIsArchivedMap.ts @@ -1,13 +1,20 @@ +import type {OnyxCollection} from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportNameValuePairs} from '@src/types/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) { @@ -15,8 +22,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 043bb910ed48..1702650dc11c 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1661,6 +1661,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, @@ -1672,12 +1687,43 @@ 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, + ]; + 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 @@ -1753,8 +1799,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; @@ -1779,10 +1825,15 @@ function createFilteredOptionList( }) : []; - return { + const result: FilteredOptionListResult = { reports: reportOptions, personalDetails: personalDetailsOptions as Array>, }; + + filteredOptionListCacheKey = cacheKey; + filteredOptionListCacheValue = result; + + return result; } function createOptionFromReport( From f8bb1db4e734346772a705bcf7748d860b8b1014 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 6 Jul 2026 13:05:33 +0200 Subject: [PATCH 2/5] Format branch files with oxfmt --- .../Search/SearchAutocompleteList.tsx | 19 +++++++++----- src/hooks/useFilteredOptions.ts | 10 ++++--- src/hooks/usePrivateIsArchivedMap.ts | 4 ++- src/libs/OptionsListUtils/index.ts | 26 ++++++++++++------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index a2099be002aa..aa353d3ccf0f 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,7 +1,3 @@ -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import type {ForwardedRef, RefObject} from 'react'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; -import type {OnyxCollection} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; @@ -9,6 +5,7 @@ import BareUserListItem from '@components/SelectionList/ListItem/BareUserListIte import type {ListItem as NewListItem, UserListItemProps} from '@components/SelectionList/ListItem/types'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; + import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; @@ -22,6 +19,7 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSortedActions from '@hooks/useSortedActions'; import useThemeStyles from '@hooks/useThemeStyles'; + import FS from '@libs/Fullstory'; import type {Options, SearchOption} from '@libs/OptionsListUtils'; import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions} from '@libs/OptionsListUtils'; @@ -35,17 +33,26 @@ import StringUtils from '@libs/StringUtils'; import {cancelSpan, endSpan, getSpan} from '@libs/telemetry/activeSpans'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {expensifyLoginsSelector} from '@libs/UserUtils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ForwardedRef, RefObject} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; + +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; + import type {SearchQueryItem, SearchQueryListItemProps} from './SearchList/ListItem/SearchQueryListItem'; -import SearchQueryListItem, {isSearchQueryItem} from './SearchList/ListItem/SearchQueryListItem'; import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions'; -import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; import type {UserFriendlyKey} from './types'; +import SearchQueryListItem, {isSearchQueryItem} from './SearchList/ListItem/SearchQueryListItem'; +import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; + type AutocompleteListItem = NewListItem & Partial> & Partial>; type GetAdditionalSectionsCallback = (options: Options, sectionIndex: number) => Array> | undefined; diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index b3be888bdc0c..d9ba02a25cbe 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -1,10 +1,14 @@ -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {useMemo, useState} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {createFilteredOptionList} from '@libs/OptionsListUtils'; import type {OptionList} from '@libs/OptionsListUtils/types'; + import ONYXKEYS from '@src/ONYXKEYS'; import type Beta from '@src/types/onyx/Beta'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {useMemo, useState} from 'react'; + import useOnyx from './useOnyx'; import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; import useReportAttributes from './useReportAttributes'; diff --git a/src/hooks/usePrivateIsArchivedMap.ts b/src/hooks/usePrivateIsArchivedMap.ts index 21b31971f564..47424991523b 100644 --- a/src/hooks/usePrivateIsArchivedMap.ts +++ b/src/hooks/usePrivateIsArchivedMap.ts @@ -1,6 +1,8 @@ -import type {OnyxCollection} from 'react-native-onyx'; 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; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 1702650dc11c..ea4fd77e1c75 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1,14 +1,9 @@ -/* eslint-disable @typescript-eslint/prefer-for-of */ -import * as Sentry from '@sentry/react-native'; -import {Str} from 'expensify-common'; -import deburr from 'lodash/deburr'; -import lodashOrderBy from 'lodash/orderBy'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import type {SetNonNullable} from 'type-fest'; import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; + import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; + import {getEnabledCategoriesCount} from '@libs/CategoryUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import filterArrayByMatch from '@libs/filterArrayByMatch'; @@ -170,6 +165,7 @@ import StringUtils from '@libs/StringUtils'; import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {getDescription, getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isScanning} from '@libs/TransactionUtils'; import {generateAccountID} from '@libs/UserUtils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { @@ -193,7 +189,17 @@ import type { } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {doesPersonalDetailMatchSearchTerm, getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from './searchMatchUtils'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {SetNonNullable} from 'type-fest'; + +/* eslint-disable @typescript-eslint/prefer-for-of */ +import * as Sentry from '@sentry/react-native'; +import {Str} from 'expensify-common'; +import deburr from 'lodash/deburr'; +import lodashOrderBy from 'lodash/orderBy'; +import Onyx from 'react-native-onyx'; + import type { FilterUserToInviteConfig, GetOptionsConfig, @@ -215,6 +221,8 @@ import type { SectionForSearchTerm, } from './types'; +import {doesPersonalDetailMatchSearchTerm, getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from './searchMatchUtils'; + /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can * be configured to display different results based on the options passed to the private getOptions() method. Public From 585061bf038fc5be1d457b7313b4cf766f7d5d92 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Mon, 6 Jul 2026 13:30:24 +0200 Subject: [PATCH 3/5] Include active locale in createFilteredOptionList cache key Option building translates strings imperatively (translateLocal), so a locale change alters the output without changing any input reference. Keying the cache on IntlStore.getCurrentLocale() makes the cached result re-localize, which the OptionsListUtils localization test guards. --- src/libs/OptionsListUtils/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 923e70de05b5..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, @@ -1654,6 +1655,8 @@ function createFilteredOptionList( 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; From 46715381a18586b462d816e7cb656a259491f526 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 7 Jul 2026 12:51:00 +0200 Subject: [PATCH 4/5] Address review feedback on filtered option list caching - Reuse OptionList type instead of a duplicate FilteredOptionListResult - Drop the unused betas option from createFilteredOptionList and useFilteredOptions - Key the option list cache per options signature (bounded) so screens no longer evict each other's entries - Clear the option list and privateIsArchivedMap caches on sign-out via a session cleanup registry - Pass the active locale into createFilteredOptionList so mounted consumers recompute on language switch - Document the Onyx collection snapshot reference-stability guarantee relied on by usePrivateIsArchivedMap - Add unit tests locking in deferContactsUntilSearch behavior --- .../Search/SearchAutocompleteList.tsx | 3 +- src/hooks/useFilteredOptions.ts | 15 ++--- src/hooks/usePrivateIsArchivedMap.ts | 13 ++++ src/hooks/useSearchSelector/base.ts | 1 - src/libs/OptionsListUtils/index.ts | 59 +++++++++++-------- src/libs/SessionCleanup.ts | 24 ++++++++ src/libs/actions/Session/index.ts | 2 + src/pages/NewChatPage/index.tsx | 1 - src/pages/Share/ShareTab.tsx | 1 - tests/unit/OptionsListUtilsTest.tsx | 15 +++++ 10 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 src/libs/SessionCleanup.ts diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index aa353d3ccf0f..7f4090a06685 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -190,8 +190,9 @@ function SearchAutocompleteList({ const {options: listOptions, isLoading: isLoadingOptions} = useFilteredOptions({ enabled: true, isSearching: !!autocompleteQueryValue.trim(), + // The empty-query state renders only recent searches and recent reports (no standalone contacts), + // so contacts can be deferred until the user types a query. deferContactsUntilSearch: true, - betas: betas ?? [], }); const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata); diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index d9ba02a25cbe..4e949f62483d 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -2,13 +2,11 @@ import {createFilteredOptionList} from '@libs/OptionsListUtils'; import type {OptionList} from '@libs/OptionsListUtils/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type Beta from '@src/types/onyx/Beta'; - -import type {OnyxEntry} from 'react-native-onyx'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {useMemo, useState} from 'react'; +import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; import useReportAttributes from './useReportAttributes'; @@ -32,8 +30,6 @@ type UseFilteredOptionsConfig = { * an option per contact on open. Leave false for contact pickers (default: false). */ deferContactsUntilSearch?: boolean; - /** Beta features the user has access to */ - betas?: OnyxEntry; }; type UseFilteredOptionsResult = { @@ -68,7 +64,6 @@ type UseFilteredOptionsResult = { * const {options, isLoading} = useFilteredOptions({ * maxRecentReports: 500, * enabled: didScreenTransitionEnd, - * betas, * }); * * */ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredOptionsResult { - const {maxRecentReports = 500, enabled = true, includeP2P = true, batchSize = 100, isSearching = false, deferContactsUntilSearch = false, betas} = config; + const {maxRecentReports = 500, enabled = true, includeP2P = true, batchSize = 100, isSearching = false, deferContactsUntilSearch = false} = config; const [reportsLimit, setReportsLimit] = useState(maxRecentReports); @@ -86,6 +81,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const reportAttributesDerived = useReportAttributes(); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + // Option building is locale-dependent, so a consumer that stays mounted through a language switch recomputes. + const {preferredLocale} = useLocalize(); const privateIsArchivedMap = usePrivateIsArchivedMap(); @@ -106,7 +103,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO includeP2P, isSearching, deferContactsUntilSearch, - betas, + locale: preferredLocale, }, undefined, undefined, @@ -124,7 +121,7 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO includeP2P, isSearching, deferContactsUntilSearch, - betas, + preferredLocale, isTrackIntentUser, ], ); diff --git a/src/hooks/usePrivateIsArchivedMap.ts b/src/hooks/usePrivateIsArchivedMap.ts index 47424991523b..f907e294ac32 100644 --- a/src/hooks/usePrivateIsArchivedMap.ts +++ b/src/hooks/usePrivateIsArchivedMap.ts @@ -1,3 +1,5 @@ +import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; + import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportNameValuePairs} from '@src/types/onyx'; @@ -9,10 +11,21 @@ type PrivateIsArchivedMap = Record; // 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. +// Reference equality on the source is reliable: Onyx hands every subscriber the same frozen +// collection snapshot and rebuilds it only when a member changes (see OnyxCache.getCollectionData +// in react-native-onyx), and a changed reference only costs a map rebuild here. let cachedSource: OnyxCollection; let cachedMap: PrivateIsArchivedMap = {}; let hasCached = false; +// The cached collection belongs to the signed-in account, so release it on sign-out +// rather than holding it until the next subscriber mounts. +registerSessionCleanupCallback(() => { + cachedSource = undefined; + cachedMap = {}; + hasCached = false; +}); + function buildPrivateIsArchivedMap(allReportNVP: OnyxCollection): PrivateIsArchivedMap { if (hasCached && allReportNVP === cachedSource) { return cachedMap; diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index 519702c1bb0d..bcb1003aca08 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -218,7 +218,6 @@ function useSearchSelectorBase({ enabled: shouldInitialize, isSearching: isSearchingOptions, batchSize: maxResultsPerPage, - betas, }); const areOptionsInitialized = !isLoadingOptions; const defaultOptions = filteredOptions ?? emptyOptionList; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 6d0200a5e673..271a0c65a64c 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -163,6 +163,7 @@ import { shouldReportBeInOptionList, shouldShowMarkAsDone, } from '@libs/ReportUtils'; +import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; import StringUtils from '@libs/StringUtils'; import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils'; import {getDescription, getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isScanning} from '@libs/TransactionUtils'; @@ -173,6 +174,7 @@ import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Beta, + Locale, Login, OnyxInputOrEntry, PersonalDetails, @@ -1597,20 +1599,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. +// which the 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; +// Cache keyed by the options signature so each selection screen configuration (SearchRouter, NewChatPage, +// ShareTab, etc.) keeps its own entry and reopening one screen is not evicted by opening another. +// An entry is reused only while its Onyx inputs are referentially unchanged. +const filteredOptionListCache = new Map(); + +// Bounds the cache when a paginating screen produces many distinct `maxRecentReports` values. +const FILTERED_OPTION_LIST_CACHE_MAX_ENTRIES = 8; + +// The cached results (and the Onyx collection references in their keys) belong to the signed-in +// account, so drop them on sign-out instead of holding them until the next call. +registerSessionCleanupCallback(() => filteredOptionListCache.clear()); function createFilteredOptionList( personalDetails: OnyxEntry, @@ -1622,7 +1625,6 @@ function createFilteredOptionList( maxRecentReports?: number; 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), @@ -1630,36 +1632,36 @@ function createFilteredOptionList( * empty state (contact pickers) must leave this false. */ deferContactsUntilSearch?: boolean; + /** Active locale; defaults to `IntlStore.getCurrentLocale()`. Pass it from a hook to recompute on language switch. */ + locale?: Locale; } = {}, policyTags?: OnyxCollection, visibleReportActionsData: VisibleReportActionsDerivedValue = EMPTY_VISIBLE_REPORT_ACTIONS, isTrackIntentUser?: boolean, -): FilteredOptionListResult { - const {maxRecentReports = 500, includeP2P = true, isSearching = false, deferContactsUntilSearch = false} = options; +): OptionList { + const {maxRecentReports = 500, includeP2P = true, isSearching = false, deferContactsUntilSearch = false, locale} = 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 = [ + const cacheEntryKey = `${maxRecentReports}_${includeP2P}_${isSearching}_${deferContactsUntilSearch}`; + const cacheInputs = [ 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(), + locale ?? IntlStore.getCurrentLocale(), ]; - if (filteredOptionListCacheValue && filteredOptionListCacheKey?.length === cacheKey.length && cacheKey.every((value, index) => value === filteredOptionListCacheKey?.[index])) { - return filteredOptionListCacheValue; + const cachedEntry = filteredOptionListCache.get(cacheEntryKey); + if (cachedEntry && cachedEntry.inputs.length === cacheInputs.length && cacheInputs.every((value, index) => value === cachedEntry.inputs.at(index))) { + return cachedEntry.result; } const reportMapForAccountIDs: Record = {}; @@ -1763,13 +1765,20 @@ function createFilteredOptionList( }) : []; - const result: FilteredOptionListResult = { + const result: OptionList = { reports: reportOptions, personalDetails: personalDetailsOptions as Array>, }; - filteredOptionListCacheKey = cacheKey; - filteredOptionListCacheValue = result; + // Re-inserting moves the entry to the end of the Map, so eviction below drops the least recently written entry. + filteredOptionListCache.delete(cacheEntryKey); + if (filteredOptionListCache.size >= FILTERED_OPTION_LIST_CACHE_MAX_ENTRIES) { + const oldestEntryKey = filteredOptionListCache.keys().next().value; + if (oldestEntryKey !== undefined) { + filteredOptionListCache.delete(oldestEntryKey); + } + } + filteredOptionListCache.set(cacheEntryKey, {inputs: cacheInputs, result}); return result; } diff --git a/src/libs/SessionCleanup.ts b/src/libs/SessionCleanup.ts new file mode 100644 index 000000000000..ff606c7c7186 --- /dev/null +++ b/src/libs/SessionCleanup.ts @@ -0,0 +1,24 @@ +type SessionCleanupCallback = () => void; + +const callbacks: SessionCleanupCallback[] = []; + +/** + * Registers a callback that releases module-level state holding the signed-in account's data + * (e.g. memory caches keyed by Onyx collections). Registered callbacks run on sign-out via + * `runSessionCleanupCallbacks` (called from `cleanupSession` in the Session actions). + * + * This module is dependency-free on purpose: Session actions can import it without pulling in + * (and creating import cycles with) the modules that own the caches. + */ +function registerSessionCleanupCallback(callback: SessionCleanupCallback) { + callbacks.push(callback); +} + +/** Runs all registered cleanup callbacks. Called on sign-out. */ +function runSessionCleanupCallbacks() { + for (const callback of callbacks) { + callback(); + } +} + +export {registerSessionCleanupCallback, runSessionCleanupCallbacks}; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 845ab1bfa1d2..83c5db7e8a01 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -36,6 +36,7 @@ import * as SequentialQueue from '@libs/Network/SequentialQueue'; import Pusher from '@libs/Pusher'; import reauthenticate from '@libs/Reauthentication'; import {getReportIDFromLink} from '@libs/ReportUtils'; +import {runSessionCleanupCallbacks} from '@libs/SessionCleanup'; import * as SessionUtils from '@libs/SessionUtils'; import {checkIfShouldUseNewPartnerName, resetDidUserLogInDuringSession} from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; @@ -1072,6 +1073,7 @@ function cleanupSession() { }); clearCachedAttachments(); clearSoundAssetsCache(); + runSessionCleanupCallbacks(); } function clearAccountMessages() { diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 0fb63ea5a43c..55d022838018 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -93,7 +93,6 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor batchSize: 100, enablePagination: true, isSearching, - betas, }); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index 53c6e6f0c12d..8b8429f98cab 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -60,7 +60,6 @@ function ShareTab() { const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); const {options: listOptions, isLoading} = useFilteredOptions({ enabled: didScreenTransitionEnd, - betas: betas ?? [], isSearching: !!debouncedTextInputValue.trim(), }); const areOptionsInitialized = !isLoading; diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index aa3d7424746c..72b55031432e 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -8329,6 +8329,21 @@ describe('OptionsListUtils', () => { expect(result).toHaveProperty('reports'); expect(result).toHaveProperty('personalDetails'); }); + + // The SearchRouter relies on this: its empty-query state renders recent reports only, + // so contacts must not be built until the user starts searching. + it('should not build personal details when deferContactsUntilSearch is true and not searching', () => { + const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, {deferContactsUntilSearch: true}); + + expect(result.reports.length).toBeGreaterThan(0); + expect(result.personalDetails.length).toBe(0); + }); + + it('should build personal details when deferContactsUntilSearch is true and searching', () => { + const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, undefined, {}, undefined, {deferContactsUntilSearch: true, isSearching: true}); + + expect(result.personalDetails.length).toBeGreaterThan(0); + }); }); describe('getFilteredRecentAttendees', () => { it('should deduplicate recent attendees by email', () => { From b43294ee7f1f913852a7ef5790f908c1b678d83d Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Tue, 7 Jul 2026 17:16:13 +0200 Subject: [PATCH 5/5] Fix correctness issues in filtered option list cache - Return shallow clones of cached options so in-place mutations by consumers (isBold/isSelected/brickRoadIndicator) don't leak across screens sharing a cache entry - Refresh entry recency on cache hit so eviction is true LRU - Track in-place-mutated sorted report actions via a version counter in the cache inputs to avoid stale last-message previews - Skip caching isSearching results to avoid retaining full-account option lists until sign-out - Export clearFilteredOptionListCache and use it in perf/unit tests so benchmarks measure the build path and tests stay order-independent - Extract buildFilteredOptionListCacheKey and document the cache size --- src/libs/OptionsListUtils/index.ts | 58 ++++++++++++++++--- tests/perf-test/OptionsListUtils.perf-test.ts | 12 ++-- tests/unit/OptionsListUtilsTest.tsx | 6 ++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 271a0c65a64c..ddbb5bb38982 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -250,6 +250,11 @@ const deprecatedAllSortedReportActions: Record = {}; const deprecatedCachedOneTransactionThreadReportIDs: Record = {}; /** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ let deprecatedAllReportActions: OnyxCollection; + +// The sorted report-actions objects above are mutated in place (their references never change), +// so this version number is the only signal that their contents were updated. +let deprecatedReportActionsVersion = 0; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, @@ -257,6 +262,7 @@ Onyx.connect({ if (!actions) { return; } + deprecatedReportActionsVersion++; deprecatedAllReportActions = actions ?? {}; // Iterate over the report actions to build the sorted report actions objects @@ -1608,9 +1614,31 @@ const EMPTY_VISIBLE_REPORT_ACTIONS: VisibleReportActionsDerivedValue = {}; // An entry is reused only while its Onyx inputs are referentially unchanged. const filteredOptionListCache = new Map(); -// Bounds the cache when a paginating screen produces many distinct `maxRecentReports` values. +// One slot per active screen configuration (~7 distinct callers) plus a small buffer. +// The LRU bound prevents a paginating screen from flooding the cache with one entry +// per distinct maxRecentReports value and evicting entries for other screens. const FILTERED_OPTION_LIST_CACHE_MAX_ENTRIES = 8; +/** Builds the cache key from the option values that define a distinct screen configuration. */ +function buildFilteredOptionListCacheKey(args: Array): string { + return args.join('_'); +} + +// Consumers (e.g. getValidOptions) mutate option objects in place (isBold/isSelected/brickRoadIndicator), +// so the cache keeps a pristine copy and every caller receives its own shallow clones, matching the +// per-call fresh objects they would get without the cache. +function cloneOptionList(optionList: OptionList): OptionList { + return { + reports: optionList.reports.map((option) => ({...option})), + personalDetails: optionList.personalDetails.map((option) => ({...option})), + }; +} + +/** Clears the createFilteredOptionList cache. For tests that measure or exercise the build path with unchanged inputs. */ +function clearFilteredOptionListCache() { + filteredOptionListCache.clear(); +} + // The cached results (and the Onyx collection references in their keys) belong to the signed-in // account, so drop them on sign-out instead of holding them until the next call. registerSessionCleanupCallback(() => filteredOptionListCache.clear()); @@ -1646,7 +1674,11 @@ function createFilteredOptionList( // does not display standalone contacts, and typing flips `isSearching` which rebuilds the full set. const shouldBuildContacts = includeP2P && !(deferContactsUntilSearch && !isSearching); - const cacheEntryKey = `${maxRecentReports}_${includeP2P}_${isSearching}_${deferContactsUntilSearch}`; + // Search-mode results contain an option for every report and contact, so caching them would retain + // full-account-sized arrays until sign-out — and any Onyx change invalidates them anyway. + const shouldUseCache = !isSearching; + + const cacheEntryKey = buildFilteredOptionListCacheKey([maxRecentReports, includeP2P, isSearching, deferContactsUntilSearch]); const cacheInputs = [ personalDetails, reports, @@ -1658,10 +1690,16 @@ function createFilteredOptionList( isTrackIntentUser, // Option building translates strings imperatively (translateLocal), so the active locale is part of the output. locale ?? IntlStore.getCurrentLocale(), + // Option building reads module-level sorted report actions (getLastMessageTextForReport) that are + // mutated in place, so their version stands in for the never-changing object reference. + deprecatedReportActionsVersion, ]; - const cachedEntry = filteredOptionListCache.get(cacheEntryKey); - if (cachedEntry && cachedEntry.inputs.length === cacheInputs.length && cacheInputs.every((value, index) => value === cachedEntry.inputs.at(index))) { - return cachedEntry.result; + const cachedEntry = shouldUseCache ? filteredOptionListCache.get(cacheEntryKey) : undefined; + if (cachedEntry && cacheInputs.every((value, index) => value === cachedEntry.inputs.at(index))) { + // Re-inserting refreshes recency so a frequently-hit entry is not evicted by writes to other keys. + filteredOptionListCache.delete(cacheEntryKey); + filteredOptionListCache.set(cacheEntryKey, cachedEntry); + return cloneOptionList(cachedEntry.result); } const reportMapForAccountIDs: Record = {}; @@ -1770,7 +1808,11 @@ function createFilteredOptionList( personalDetails: personalDetailsOptions as Array>, }; - // Re-inserting moves the entry to the end of the Map, so eviction below drops the least recently written entry. + if (!shouldUseCache) { + return result; + } + + // Re-inserting moves the entry to the end of the Map, so eviction below drops the least recently used entry. filteredOptionListCache.delete(cacheEntryKey); if (filteredOptionListCache.size >= FILTERED_OPTION_LIST_CACHE_MAX_ENTRIES) { const oldestEntryKey = filteredOptionListCache.keys().next().value; @@ -1780,7 +1822,8 @@ function createFilteredOptionList( } filteredOptionListCache.set(cacheEntryKey, {inputs: cacheInputs, result}); - return result; + // The caller gets clones because the cached entry must stay pristine (see cloneOptionList). + return cloneOptionList(result); } function createOptionFromReport( @@ -3440,6 +3483,7 @@ function processSearchString(searchString: string | undefined): string[] { export { canCreateOptimisticPersonalDetailOption, + clearFilteredOptionListCache, combineOrderingOfReportsAndPersonalDetails, createOptionFromReport, createFilteredOptionList, diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 47cd39caa4e5..f4f5c121e0ce 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -1,6 +1,6 @@ import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; -import {createFilteredOptionList, filterAndOrderOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; +import {clearFilteredOptionListCache, createFilteredOptionList, filterAndOrderOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -276,12 +276,14 @@ describe('OptionsListUtils', () => { test('[OptionsListUtils] createFilteredOptionList', async () => { await waitForBatchedUpdates(); - await measureFunction(() => - createFilteredOptionList(personalDetails, mockedReportsMap, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { + await measureFunction(() => { + // Inputs are referentially identical across measured runs, so clear the cache to measure the build path. + clearFilteredOptionListCache(); + return createFilteredOptionList(personalDetails, mockedReportsMap, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { maxRecentReports: 500, isSearching: false, - }), - ); + }); + }); }); test('[OptionsListUtils] createFilteredOptionList with isSearching is true', async () => { diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 72b55031432e..2ca0a548b241 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -14,6 +14,7 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTop import type {OptionList, Options, SearchOption, SearchOptionData} from '@libs/OptionsListUtils'; import { canCreateOptimisticPersonalDetailOption, + clearFilteredOptionListCache, createFilteredOptionList, createOption, createOptionFromReport, @@ -858,6 +859,11 @@ describe('OptionsListUtils', () => { ); }); + // createFilteredOptionList caches results at module level; clear it so tests stay order-independent. + beforeEach(() => { + clearFilteredOptionListCache(); + }); + describe('getSearchOptions()', () => { it('should return all options when no search value is provided', () => { // Given a set of options