Skip to content

Commit 457a4e9

Browse files
authored
Merge pull request Expensify#63627 from callstack-internal/62335-recent-searches
Search Autocomplete: Always Render with Loading Skeleton & add isReady focus guard
2 parents 49e0760 + 004a16e commit 457a4e9

3 files changed

Lines changed: 94 additions & 57 deletions

File tree

src/components/OptionListContextProvider.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
1+
import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react';
2+
import {InteractionManager} from 'react-native';
23
import {useOnyx} from 'react-native-onyx';
34
import type {OnyxCollection} from 'react-native-onyx';
45
import usePrevious from '@hooks/usePrevious';
6+
import getPlatform from '@libs/getPlatform';
57
import {createOptionFromReport, createOptionList, processReport} from '@libs/OptionsListUtils';
68
import type {OptionList, SearchOption} from '@libs/OptionsListUtils';
79
import {isSelfDM} from '@libs/ReportUtils';
10+
import CONST from '@src/CONST';
811
import ONYXKEYS from '@src/ONYXKEYS';
912
import type {PersonalDetails, Report} from '@src/types/onyx';
1013
import {usePersonalDetails} from './OnyxProvider';
@@ -42,7 +45,8 @@ const isEqualPersonalDetail = (prevPersonalDetail: PersonalDetails, personalDeta
4245
prevPersonalDetail?.displayName === personalDetail?.displayName;
4346

4447
function OptionsListContextProvider({children}: OptionsListProviderProps) {
45-
const areOptionsInitialized = useRef(false);
48+
const [areOptionsInitialized, setAreOptionsInitialized] = useState(false);
49+
4650
const [options, setOptions] = useState<OptionList>({
4751
reports: [],
4852
personalDetails: [],
@@ -67,12 +71,12 @@ function OptionsListContextProvider({children}: OptionsListProviderProps) {
6771
* This effect is responsible for generating the options list when their data is not yet initialized
6872
*/
6973
useEffect(() => {
70-
if (!areOptionsInitialized.current || !reports || hasInitialData) {
74+
if (!areOptionsInitialized || !reports || hasInitialData) {
7175
return;
7276
}
7377

7478
loadOptions();
75-
}, [reports, personalDetails, hasInitialData, loadOptions]);
79+
}, [reports, personalDetails, hasInitialData, loadOptions, areOptionsInitialized]);
7680

7781
/**
7882
* This effect is responsible for generating the options list when the locale changes
@@ -102,7 +106,7 @@ function OptionsListContextProvider({children}: OptionsListProviderProps) {
102106
* This effect is responsible for updating the options only for changed reports
103107
*/
104108
useEffect(() => {
105-
if (!changedReportsEntries || !areOptionsInitialized.current) {
109+
if (!changedReportsEntries || !areOptionsInitialized) {
106110
return;
107111
}
108112

@@ -130,10 +134,10 @@ function OptionsListContextProvider({children}: OptionsListProviderProps) {
130134
reports: Array.from(updatedReportsMap.values()),
131135
};
132136
});
133-
}, [changedReportsEntries, personalDetails]);
137+
}, [areOptionsInitialized, changedReportsEntries, personalDetails]);
134138

135139
useEffect(() => {
136-
if (!changedReportActions || !areOptionsInitialized.current) {
140+
if (!changedReportActions || !areOptionsInitialized) {
137141
return;
138142
}
139143

@@ -162,14 +166,14 @@ function OptionsListContextProvider({children}: OptionsListProviderProps) {
162166
reports: Array.from(updatedReportsMap.values()),
163167
};
164168
});
165-
}, [changedReportActions, personalDetails]);
169+
}, [areOptionsInitialized, changedReportActions, personalDetails]);
166170

167171
/**
168172
* This effect is used to update the options list when personal details change.
169173
*/
170174
useEffect(() => {
171175
// there is no need to update the options if the options are not initialized
172-
if (!areOptionsInitialized.current) {
176+
if (!areOptionsInitialized) {
173177
return;
174178
}
175179

@@ -233,24 +237,30 @@ function OptionsListContextProvider({children}: OptionsListProviderProps) {
233237

234238
const initializeOptions = useCallback(() => {
235239
loadOptions();
236-
areOptionsInitialized.current = true;
240+
if (getPlatform() === CONST.PLATFORM.ANDROID || getPlatform() === CONST.PLATFORM.IOS) {
241+
InteractionManager.runAfterInteractions(() => {
242+
setAreOptionsInitialized(true);
243+
});
244+
return;
245+
}
246+
setAreOptionsInitialized(true);
237247
}, [loadOptions]);
238248

239249
const resetOptions = useCallback(() => {
240-
if (!areOptionsInitialized.current) {
250+
if (!areOptionsInitialized) {
241251
return;
242252
}
243253

244-
areOptionsInitialized.current = false;
254+
setAreOptionsInitialized(false);
245255
setOptions({
246256
reports: [],
247257
personalDetails: [],
248258
});
249-
}, []);
259+
}, [areOptionsInitialized]);
250260

251261
return (
252262
<OptionsListContext.Provider // eslint-disable-next-line react-compiler/react-compiler
253-
value={useMemo(() => ({options, initializeOptions, areOptionsInitialized: areOptionsInitialized.current, resetOptions}), [options, initializeOptions, resetOptions])}
263+
value={useMemo(() => ({options, initializeOptions, areOptionsInitialized, resetOptions}), [options, initializeOptions, areOptionsInitialized, resetOptions])}
254264
>
255265
{children}
256266
</OptionsListContext.Provider>
@@ -263,15 +273,14 @@ const useOptionsListContext = () => useContext(OptionsListContext);
263273
const useOptionsList = (options?: {shouldInitialize: boolean}) => {
264274
const {shouldInitialize = true} = options ?? {};
265275
const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext();
266-
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: false});
267276

268277
useEffect(() => {
269-
if (!shouldInitialize || areOptionsInitialized || isLoadingApp) {
278+
if (!shouldInitialize || areOptionsInitialized) {
270279
return;
271280
}
272281

273282
initializeOptions();
274-
}, [shouldInitialize, initializeOptions, areOptionsInitialized, isLoadingApp]);
283+
}, [shouldInitialize, initializeOptions, areOptionsInitialized]);
275284

276285
return {
277286
initializeOptions,

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {useOnyx} from 'react-native-onyx';
55
import * as Expensicons from '@components/Icon/Expensicons';
66
import {usePersonalDetails} from '@components/OnyxProvider';
77
import {useOptionsList} from '@components/OptionListContextProvider';
8+
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
89
import type {AnimatedTextInputRef} from '@components/RNTextInput';
910
import SelectionList from '@components/SelectionList';
1011
import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
@@ -369,21 +370,30 @@ function SearchAutocompleteList(
369370
.filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
370371
.sort();
371372

372-
return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE, text: type}));
373+
return filteredTypes.map((type) => ({
374+
filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE,
375+
text: type,
376+
}));
373377
}
374378
case CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY: {
375379
const filteredGroupBy = groupByAutocompleteList.filter(
376380
(groupByValue) => groupByValue.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(groupByValue.toLowerCase()),
377381
);
378-
return filteredGroupBy.map((groupByValue) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.GROUP_BY, text: groupByValue}));
382+
return filteredGroupBy.map((groupByValue) => ({
383+
filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.GROUP_BY,
384+
text: groupByValue,
385+
}));
379386
}
380387
case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
381388
const filteredStatuses = statusAutocompleteList
382389
.filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
383390
.sort()
384391
.slice(0, 10);
385392

386-
return filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.STATUS, text: status}));
393+
return filteredStatuses.map((status) => ({
394+
filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.STATUS,
395+
text: status,
396+
}));
387397
}
388398
case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
389399
const filteredExpenseTypes = expenseTypes
@@ -549,7 +559,10 @@ function SearchAutocompleteList(
549559
text: StringUtils.lineBreaksToSpaces(item.text),
550560
wrapperStyle: [styles.pr3, styles.pl3],
551561
}));
552-
sections.push({title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, data: styledRecentReports});
562+
sections.push({
563+
title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined,
564+
data: styledRecentReports,
565+
});
553566

554567
if (autocompleteSuggestions.length > 0) {
555568
const autocompleteData = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => {
@@ -592,9 +605,14 @@ function SearchAutocompleteList(
592605
}, [autocompleteQueryValue, onHighlightFirstItem, normalizedReferenceText]);
593606

594607
return (
595-
// On page refresh, when the list is rendered before options are initialized the auto-focusing on initiallyFocusedOptionKey
596-
// will fail because the list will be empty on first render so we only render after options are initialized.
597-
areOptionsInitialized && (
608+
<>
609+
{isInitialRender && (
610+
<OptionsListSkeletonView
611+
fixedNumItems={4}
612+
shouldStyleAsTable
613+
speed={CONST.TIMING.SKELETON_ANIMATION_SPEED}
614+
/>
615+
)}
598616
<SelectionList<OptionData | SearchQueryItem>
599617
showLoadingPlaceholder={!areOptionsInitialized}
600618
fixedNumItemsForLoader={4}
@@ -623,7 +641,7 @@ function SearchAutocompleteList(
623641
shouldSubscribeToArrowKeyEvents={shouldSubscribeToArrowKeyEvents}
624642
disableKeyboardShortcuts={!shouldSubscribeToArrowKeyEvents}
625643
/>
626-
)
644+
</>
627645
);
628646
}
629647

src/components/Search/SearchRouter/SearchRouter.tsx

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {useOnyx} from 'react-native-onyx';
77
import type {ValueOf} from 'type-fest';
88
import HeaderWithBackButton from '@components/HeaderWithBackButton';
99
import * as Expensicons from '@components/Icon/Expensicons';
10+
import {useOptionsList} from '@components/OptionListContextProvider';
11+
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
1012
import type {AnimatedTextInputRef} from '@components/RNTextInput';
1113
import type {GetAdditionalSectionsCallback} from '@components/Search/SearchAutocompleteList';
1214
import SearchAutocompleteList from '@components/Search/SearchAutocompleteList';
@@ -79,8 +81,10 @@ type SearchRouterProps = {
7981
function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDisplayed}: SearchRouterProps, ref: React.Ref<View>) {
8082
const {translate} = useLocalize();
8183
const styles = useThemeStyles();
82-
const [, recentSearchesMetadata] = useOnyx(ONYXKEYS.RECENT_SEARCHES, {canBeMissing: true});
8384
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true});
85+
const [, recentSearchesMetadata] = useOnyx(ONYXKEYS.RECENT_SEARCHES, {canBeMissing: true});
86+
const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata);
87+
const {areOptionsInitialized} = useOptionsList();
8488

8589
const {shouldUseNarrowLayout} = useResponsiveLayout();
8690
const listRef = useRef<SelectionListHandle>(null);
@@ -316,8 +320,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
316320
});
317321

318322
const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
319-
const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata);
320-
323+
const shouldShowSearchList = areOptionsInitialized && isRecentSearchesDataLoaded;
321324
return (
322325
<View
323326
style={[styles.flex1, modalWidth, styles.h100, !shouldUseNarrowLayout && styles.mh85vh]}
@@ -333,33 +336,33 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
333336
shouldDisplayHelpButton={false}
334337
/>
335338
)}
336-
{isRecentSearchesDataLoaded && (
337-
<>
338-
<SearchInputSelectionWrapper
339-
value={textInputValue}
340-
isFullWidth={shouldUseNarrowLayout}
341-
onSearchQueryChange={onSearchQueryChange}
342-
onSubmit={() => {
343-
const focusedOption = listRef.current?.getFocusedOption();
344-
345-
if (!focusedOption) {
346-
submitSearch(textInputValue);
347-
return;
348-
}
349-
350-
onListItemPress(focusedOption);
351-
}}
352-
caretHidden={shouldHideInputCaret}
353-
autocompleteListRef={listRef}
354-
shouldShowOfflineMessage
355-
wrapperStyle={{...styles.border, ...styles.alignItemsCenter}}
356-
outerWrapperStyle={[shouldUseNarrowLayout ? styles.mv3 : styles.mv2, shouldUseNarrowLayout ? styles.mh5 : styles.mh2]}
357-
wrapperFocusedStyle={styles.borderColorFocus}
358-
isSearchingForReports={isSearchingForReports}
359-
selection={selection}
360-
substitutionMap={autocompleteSubstitutions}
361-
ref={textInputRef}
362-
/>
339+
<>
340+
<SearchInputSelectionWrapper
341+
value={textInputValue}
342+
isFullWidth={shouldUseNarrowLayout}
343+
onSearchQueryChange={onSearchQueryChange}
344+
onSubmit={() => {
345+
const focusedOption = listRef.current?.getFocusedOption();
346+
347+
if (!focusedOption) {
348+
submitSearch(textInputValue);
349+
return;
350+
}
351+
352+
onListItemPress(focusedOption);
353+
}}
354+
caretHidden={shouldHideInputCaret}
355+
autocompleteListRef={listRef}
356+
shouldShowOfflineMessage
357+
wrapperStyle={{...styles.border, ...styles.alignItemsCenter}}
358+
outerWrapperStyle={[shouldUseNarrowLayout ? styles.mv3 : styles.mv2, shouldUseNarrowLayout ? styles.mh5 : styles.mh2]}
359+
wrapperFocusedStyle={styles.borderColorFocus}
360+
isSearchingForReports={isSearchingForReports}
361+
selection={selection}
362+
substitutionMap={autocompleteSubstitutions}
363+
ref={textInputRef}
364+
/>
365+
{shouldShowSearchList && (
363366
<SearchAutocompleteList
364367
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
365368
handleSearch={searchInServer}
@@ -372,8 +375,15 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
372375
ref={listRef}
373376
textInputRef={textInputRef}
374377
/>
375-
</>
376-
)}
378+
)}
379+
{!shouldShowSearchList && (
380+
<OptionsListSkeletonView
381+
fixedNumItems={4}
382+
shouldStyleAsTable
383+
speed={CONST.TIMING.SKELETON_ANIMATION_SPEED}
384+
/>
385+
)}
386+
</>
377387
</View>
378388
);
379389
}

0 commit comments

Comments
 (0)