Skip to content

Commit 312508a

Browse files
authored
Merge pull request Expensify#62953 from callstack-internal/62106-options
Postpone FastSearch generation to reduce Show modal open time
2 parents 93a63a6 + 4858b86 commit 312508a

12 files changed

Lines changed: 594 additions & 63 deletions

src/CONST/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,6 +1487,7 @@ const CONST = {
14871487
PLAY_SOUND_MESSAGE_DEBOUNCE_TIME: 500,
14881488
SKELETON_ANIMATION_SPEED: 3,
14891489
SEARCH_OPTIONS_COMPARISON: 'search_options_comparison',
1490+
SEARCH_MOST_RECENT_OPTIONS: 'search_most_recent_options',
14901491
},
14911492
PRIORITY_MODE: {
14921493
GSD: 'gsd',

src/components/OptionListContextProvider.tsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo, useRe
22
import {useOnyx} from 'react-native-onyx';
33
import type {OnyxCollection} from 'react-native-onyx';
44
import usePrevious from '@hooks/usePrevious';
5-
import {createOptionFromReport, createOptionList, processReport} from '@libs/OptionsListUtils';
5+
import {createOptionFromReport, createOptionList, processReport, shallowOptionsListCompare} from '@libs/OptionsListUtils';
66
import type {OptionList, SearchOption} from '@libs/OptionsListUtils';
77
import {isSelfDM} from '@libs/ReportUtils';
88
import ONYXKEYS from '@src/ONYXKEYS';
@@ -264,6 +264,26 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => {
264264
const {shouldInitialize = true} = options ?? {};
265265
const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext();
266266
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: false});
267+
const [internalOptions, setInternalOptions] = useState<OptionList>(optionsList);
268+
const prevOptions = useRef<OptionList>(null);
269+
270+
useEffect(() => {
271+
if (!prevOptions.current) {
272+
prevOptions.current = optionsList;
273+
setInternalOptions(optionsList);
274+
return;
275+
}
276+
/**
277+
* optionsList reference can change multiple times even the value of its arrays is the same. We perform shallow comparison to check if the options have truly changed.
278+
* This is necessary to avoid unnecessary re-renders in components that use this context.
279+
*/
280+
const areOptionsEqual = shallowOptionsListCompare(prevOptions.current, optionsList);
281+
prevOptions.current = optionsList;
282+
if (areOptionsEqual) {
283+
return;
284+
}
285+
setInternalOptions(optionsList);
286+
}, [optionsList]);
267287

268288
useEffect(() => {
269289
if (!shouldInitialize || areOptionsInitialized || isLoadingApp) {
@@ -273,12 +293,15 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => {
273293
initializeOptions();
274294
}, [shouldInitialize, initializeOptions, areOptionsInitialized, isLoadingApp]);
275295

276-
return {
277-
initializeOptions,
278-
options: optionsList,
279-
areOptionsInitialized,
280-
resetOptions,
281-
};
296+
return useMemo(
297+
() => ({
298+
initializeOptions,
299+
options: internalOptions,
300+
areOptionsInitialized,
301+
resetOptions,
302+
}),
303+
[initializeOptions, internalOptions, areOptionsInitialized, resetOptions],
304+
);
282305
};
283306

284307
export default OptionsListContextProvider;

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {SearchQueryItem, SearchQueryListItemProps} from '@components/Select
1111
import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem';
1212
import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types';
1313
import UserListItem from '@components/SelectionList/UserListItem';
14+
import useDebounce from '@hooks/useDebounce';
1415
import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions';
1516
import useLocalize from '@hooks/useLocalize';
1617
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -19,7 +20,7 @@ import {getCardFeedKey, getCardFeedNamesWithType} from '@libs/CardFeedUtils';
1920
import {getCardDescription, isCard, isCardHiddenFromSearch, mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils';
2021
import memoize from '@libs/memoize';
2122
import type {Options, SearchOption} from '@libs/OptionsListUtils';
22-
import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions, getValidPersonalDetailOptions} from '@libs/OptionsListUtils';
23+
import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions, getValidPersonalDetailOptions, optionsOrderBy, recentReportComparator} from '@libs/OptionsListUtils';
2324
import Performance from '@libs/Performance';
2425
import {getAllTaxRates, getCleanedTagName, shouldShowPolicy} from '@libs/PolicyUtils';
2526
import type {OptionData} from '@libs/ReportUtils';
@@ -353,9 +354,8 @@ function SearchAutocompleteList(
353354
}));
354355
}
355356
case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
356-
const filteredChats = searchOptions.recentReports
357-
.filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase()))
358-
.slice(0, 10);
357+
const filterChats = (chat: OptionData) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase());
358+
const filteredChats = optionsOrderBy(searchOptions.recentReports, 10, recentReportComparator, filterChats);
359359

360360
return filteredChats.map((chat) => ({
361361
filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN,
@@ -494,35 +494,43 @@ function SearchAutocompleteList(
494494
/**
495495
* Builds a suffix tree and returns a function to search in it.
496496
*/
497-
const filterOptions = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true});
497+
const {search: filterOptions, isInitialized: isFastSearchInitialized} = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true});
498498

499499
const recentReportsOptions = useMemo(() => {
500-
if (autocompleteQueryValue.trim() === '') {
501-
return searchOptions.recentReports.slice(0, 20);
500+
Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
501+
if (autocompleteQueryValue.trim() === '' || !isFastSearchInitialized) {
502+
const orderedReportOptions = optionsOrderBy(searchOptions.recentReports, 20, recentReportComparator);
503+
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
504+
return orderedReportOptions;
502505
}
503506

504-
Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
505507
const filteredOptions = filterOptions(autocompleteQueryValue);
506508
const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, {
507509
sortByReportTypeInSearch: true,
508510
preferChatRoomsOverThreads: true,
509511
});
510-
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
511512

512513
const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails];
513514
if (filteredOptions.userToInvite) {
514515
reportOptions.push(filteredOptions.userToInvite);
515516
}
517+
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
516518
return reportOptions.slice(0, 20);
517-
}, [autocompleteQueryValue, filterOptions, searchOptions]);
519+
}, [autocompleteQueryValue, filterOptions, searchOptions, isFastSearchInitialized]);
518520

519-
useEffect(() => {
520-
if (!handleSearch) {
521-
return;
522-
}
521+
const debounceHandleSearch = useDebounce(
522+
useCallback(() => {
523+
if (!handleSearch || !autocompleteQueryWithoutFilters) {
524+
return;
525+
}
526+
handleSearch(autocompleteQueryWithoutFilters);
527+
}, [handleSearch, autocompleteQueryWithoutFilters]),
528+
CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME,
529+
);
523530

524-
handleSearch(autocompleteQueryWithoutFilters);
525-
}, [autocompleteQueryWithoutFilters, handleSearch]);
531+
useEffect(() => {
532+
debounceHandleSearch();
533+
}, [autocompleteQueryWithoutFilters, debounceHandleSearch]);
526534

527535
/* Sections generation */
528536
const sections: Array<SectionListDataType<OptionData | SearchQueryItem>> = [];

src/hooks/useFastSearchFromOptions.ts

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import deburr from 'lodash/deburr';
22
import {useCallback, useEffect, useRef, useState} from 'react';
3+
import {InteractionManager} from 'react-native';
34
import Timing from '@libs/actions/Timing';
45
import FastSearch from '@libs/FastSearch';
56
import type {Options as OptionsListType, ReportAndPersonalDetailOptions} from '@libs/OptionsListUtils';
@@ -9,15 +10,15 @@ import type {OptionData} from '@libs/ReportUtils';
910
import StringUtils from '@libs/StringUtils';
1011
import CONST from '@src/CONST';
1112

12-
type AllOrSelectiveOptions = ReportAndPersonalDetailOptions | OptionsListType;
13-
1413
type Options = {
1514
includeUserToInvite: boolean;
1615
};
1716

1817
const emptyResult = {
1918
personalDetails: [],
2019
recentReports: [],
20+
userToInvite: null,
21+
currentUserOption: undefined,
2122
};
2223

2324
const personalDetailToSearchString = (option: OptionData) => {
@@ -48,9 +49,18 @@ const getRecentReportUniqueId = (option: OptionData) => {
4849
};
4950

5051
// You can either use this to search within report and personal details options
51-
function useFastSearchFromOptions(options: ReportAndPersonalDetailOptions, config?: {includeUserToInvite: false}): (searchInput: string) => ReportAndPersonalDetailOptions;
52+
function useFastSearchFromOptions(
53+
options: ReportAndPersonalDetailOptions,
54+
config?: {includeUserToInvite: false},
55+
): {search: (searchInput: string) => ReportAndPersonalDetailOptions; isInitialized: boolean};
5256
// Or you can use this to include the user invite option. This will require passing all options
53-
function useFastSearchFromOptions(options: OptionsListType, config?: {includeUserToInvite: true}): (searchInput: string) => OptionsListType;
57+
function useFastSearchFromOptions(
58+
options: OptionsListType,
59+
config?: {includeUserToInvite: true},
60+
): {
61+
search: (searchInput: string) => OptionsListType;
62+
isInitialized: boolean;
63+
};
5464

5565
/**
5666
* Hook for making options from OptionsListUtils searchable with FastSearch.
@@ -64,43 +74,49 @@ function useFastSearchFromOptions(options: OptionsListType, config?: {includeUse
6474
function useFastSearchFromOptions(
6575
options: ReportAndPersonalDetailOptions | OptionsListType,
6676
{includeUserToInvite}: Options = {includeUserToInvite: false},
67-
): (searchInput: string) => AllOrSelectiveOptions {
77+
): {search: (searchInput: string) => OptionsListType; isInitialized: boolean} {
6878
const [fastSearch, setFastSearch] = useState<ReturnType<typeof FastSearch.createFastSearch<OptionData>> | null>(null);
79+
const [isInitialized, setIsInitialized] = useState(false);
6980
const prevOptionsRef = useRef<typeof options | null>(null);
7081
const prevFastSearchRef = useRef<ReturnType<typeof FastSearch.createFastSearch<OptionData>> | null>(null);
7182

7283
useEffect(() => {
84+
let newFastSearch: ReturnType<typeof FastSearch.createFastSearch<OptionData>>;
7385
const prevOptions = prevOptionsRef.current;
7486
if (prevOptions && shallowCompareOptions(prevOptions, options)) {
7587
return;
7688
}
89+
InteractionManager.runAfterInteractions(() => {
90+
prevOptionsRef.current = options;
91+
prevFastSearchRef.current?.dispose();
92+
newFastSearch = FastSearch.createFastSearch(
93+
[
94+
{
95+
data: options.personalDetails,
96+
toSearchableString: personalDetailToSearchString,
97+
uniqueId: getPersonalDetailUniqueId,
98+
},
99+
{
100+
data: options.recentReports,
101+
toSearchableString: recentReportToSearchString,
102+
uniqueId: getRecentReportUniqueId,
103+
},
104+
],
105+
106+
{shouldStoreSearchableStrings: true},
107+
);
108+
setFastSearch(newFastSearch);
109+
prevFastSearchRef.current = newFastSearch;
110+
setIsInitialized(true);
111+
});
77112

78-
prevOptionsRef.current = options;
79-
prevFastSearchRef.current?.dispose();
80-
81-
const newFastSearch = FastSearch.createFastSearch(
82-
[
83-
{
84-
data: options.personalDetails,
85-
toSearchableString: personalDetailToSearchString,
86-
uniqueId: getPersonalDetailUniqueId,
87-
},
88-
{
89-
data: options.recentReports,
90-
toSearchableString: recentReportToSearchString,
91-
uniqueId: getRecentReportUniqueId,
92-
},
93-
],
94-
{shouldStoreSearchableStrings: true},
95-
);
96-
setFastSearch(newFastSearch);
97-
prevFastSearchRef.current = newFastSearch;
113+
return () => newFastSearch?.dispose();
98114
}, [options]);
99115

100116
useEffect(() => () => prevFastSearchRef.current?.dispose(), []);
101117

102118
const findInSearchTree = useCallback(
103-
(searchInput: string): AllOrSelectiveOptions => {
119+
(searchInput: string): OptionsListType => {
104120
if (!fastSearch) {
105121
return emptyResult;
106122
}
@@ -150,12 +166,14 @@ function useFastSearchFromOptions(
150166
return {
151167
personalDetails,
152168
recentReports,
169+
userToInvite: null,
170+
currentUserOption: undefined,
153171
};
154172
},
155173
[includeUserToInvite, options, fastSearch],
156174
);
157175

158-
return findInSearchTree;
176+
return {search: findInSearchTree, isInitialized};
159177
}
160178

161179
/**

0 commit comments

Comments
 (0)