Skip to content
Draft
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
7 changes: 6 additions & 1 deletion src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
24 changes: 22 additions & 2 deletions src/hooks/useFilteredOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Beta[]>;
};
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -99,14 +105,28 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO
maxRecentReports: reportsLimit,
includeP2P,
isSearching,
deferContactsUntilSearch,
betas,
},
undefined,
undefined,
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
Expand Down
30 changes: 25 additions & 5 deletions src/hooks/usePrivateIsArchivedMap.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
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<string, boolean>;

/**
* 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<ReportNameValuePairs>;
let cachedMap: PrivateIsArchivedMap = {};
let hasCached = false;

function buildPrivateIsArchivedMap(allReportNVP: OnyxCollection<ReportNameValuePairs>): PrivateIsArchivedMap {
if (hasCached && allReportNVP === cachedSource) {
return cachedMap;
}

const map: PrivateIsArchivedMap = {};
if (allReportNVP) {
for (const [key, value] of Object.entries(allReportNVP)) {
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};
66 changes: 60 additions & 6 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SearchOption<Report>>;
personalDetails: Array<SearchOption<PersonalDetails>>;
};

// 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<PersonalDetailsList>,
reports: OnyxCollection<Report>,
Expand All @@ -1607,12 +1623,45 @@ function createFilteredOptionList(
includeP2P?: boolean;
isSearching?: boolean;
betas?: OnyxEntry<Beta[]>;
/**
* 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<PolicyTagLists>,
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<number, Report> = {};

// Step 1: Pre-filter reports to avoid processing thousands
Expand Down Expand Up @@ -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;

Expand All @@ -1714,10 +1763,15 @@ function createFilteredOptionList(
})
: [];

return {
const result: FilteredOptionListResult = {
reports: reportOptions,
personalDetails: personalDetailsOptions as Array<SearchOption<PersonalDetails>>,
};

filteredOptionListCacheKey = cacheKey;
filteredOptionListCacheValue = result;

return result;
}

function createOptionFromReport(
Expand Down
Loading