Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/components/Search/SearchAutocompleteList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ function SearchAutocompleteList({
} = useFilteredOptions({
enabled: true,
isSearching: !!autocompleteQueryValue.trim(),
betas: betas ?? [],
// 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,
maxRecentReports: INITIAL_MAX_RECENT_REPORTS,
batchSize: RECENT_REPORTS_BATCH_SIZE,
});
Expand Down
43 changes: 34 additions & 9 deletions src/hooks/useFilteredOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ 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 {useCallback, useMemo, useState} from 'react';

import useLocalize from './useLocalize';
import useOnyx from './useOnyx';
import usePrivateIsArchivedMap from './usePrivateIsArchivedMap';
import useReportAttributes from './useReportAttributes';
import useSortedActions from './useSortedActions';

type UseFilteredOptionsConfig = {
/** Maximum number of recent reports to pre-filter and process (default: 500). */
Expand All @@ -26,8 +25,12 @@ type UseFilteredOptionsConfig = {
enablePagination?: boolean;
/** Whether search mode is active - when true, builds full report map for personal details (default: false) */
isSearching?: boolean;
/** Beta features the user has access to */
betas?: OnyxEntry<Beta[]>;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed betas because it was dead code: createFilteredOptionList never read it (only declared in the options type), and it was the one input excluded from the cache key — a trap if someone ever started using it. No behavior change: screens still pass betas to getSearchOptions/getValidOptions, where beta filtering actually happens.

/**
* 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;
};

type UseFilteredOptionsResult = {
Expand Down Expand Up @@ -64,7 +67,6 @@ type UseFilteredOptionsResult = {
* const {options, isLoading} = useFilteredOptions({
* maxRecentReports: 500,
* enabled: didScreenTransitionEnd,
* betas,
* });
*
* <SelectionList
Expand All @@ -73,7 +75,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} = config;

const [reportsLimit, setReportsLimit] = useState(maxRecentReports);

Expand All @@ -83,6 +85,13 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO
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.
Comment thread
sumo-slonik marked this conversation as resolved.
const {preferredLocale} = useLocalize();

// Sorted report actions from the RAM_ONLY_SORTED_REPORT_ACTIONS derived value; a new reference on
// every recompute, so it doubles as the report-actions invalidation signal for the option-list cache.
const sortedActions = useSortedActions();

const privateIsArchivedMap = usePrivateIsArchivedMap();

const totalReports = allReports ? Object.keys(allReports).length : 0;
Expand All @@ -101,14 +110,30 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO
maxRecentReports: reportsLimit,
includeP2P,
isSearching,
betas,
deferContactsUntilSearch,
locale: preferredLocale,
},
undefined,
undefined,
isTrackIntentUser,
sortedActions,
)
: null,
[enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas, isTrackIntentUser],
[
enabled,
allReports,
allPersonalDetails,
reportAttributesDerived,
privateIsArchivedMap,
allPolicies,
reportsLimit,
includeP2P,
isSearching,
deferContactsUntilSearch,
preferredLocale,
isTrackIntentUser,
sortedActions,
],
);

// When isSearching is set to true, the createFilteredOptionList returns all reports
Expand Down
51 changes: 43 additions & 8 deletions src/hooks/usePrivateIsArchivedMap.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,57 @@
import {registerSessionCleanupCallback} from '@libs/SessionCleanup';

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>;
// Readonly because consumers receive a shared cached reference (see below) — a write would corrupt it for every other consumer.
type PrivateIsArchivedMap = Readonly<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.
// 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.
// The initial `undefined` doubles as the "nothing cached yet" marker: it only matches an equally
// undefined (unloaded) collection, for which the correct result is the empty map anyway.
let cachedSource: OnyxCollection<ReportNameValuePairs>;
let cachedMap: PrivateIsArchivedMap = {};

// 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 = {};
});
Comment thread
mountiny marked this conversation as resolved.

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

const map: PrivateIsArchivedMap = {};
const map: Record<string, boolean> = {};
if (allReportNVP) {
for (const [key, value] of Object.entries(allReportNVP)) {
map[key] = !!value?.private_isArchived;
}
}
return map;

cachedSource = allReportNVP;
// Freezing in dev makes a write through a type cast throw instead of silently corrupting the shared cache.
cachedMap = __DEV__ ? Object.freeze(map) : map;
return cachedMap;
}

/**
* Hook that returns a map of report IDs to their private_isArchived values.
* The returned map is a shared cached reference — read-only by type, and frozen in dev.
*/
function usePrivateIsArchivedMap(): PrivateIsArchivedMap {
const [allReportNVP] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
return buildPrivateIsArchivedMap(allReportNVP);
}

export default usePrivateIsArchivedMap;
Expand Down
1 change: 0 additions & 1 deletion src/hooks/useSearchSelector/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ function useSearchSelectorBase({
enabled: shouldInitialize,
isSearching: isSearchingOptions,
batchSize: maxResultsPerPage,
betas,
});
const areOptionsInitialized = !isLoadingOptions;
const defaultOptions = filteredOptions ?? emptyOptionList;
Expand Down
Loading
Loading