diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index b2cb1b5f5293..b6baa2f14cb5 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -52,6 +52,8 @@ type GetAdditionalSectionsCallback = (options: Options, sectionIndex: number) => type SearchAutocompleteListProps = { /** Value of TextInput */ autocompleteQueryValue: string; + /** Immediate (non-debounced) query from the input for UI-only behavior */ + inputQueryValue?: string; /** Callback to trigger search action * */ handleSearch: (value: string) => void; @@ -140,6 +142,7 @@ function SearchRouterItem(props: UserListItemProps | Searc function SearchAutocompleteList({ autocompleteQueryValue, + inputQueryValue, handleSearch, searchQueryItems, getAdditionalSections, @@ -170,6 +173,9 @@ function SearchAutocompleteList({ const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; + const isInputAheadOfDebounce = effectiveInputQueryValue !== autocompleteQueryValue; + const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -242,7 +248,7 @@ function SearchAutocompleteList({ ]); const [isInitialRender, setIsInitialRender] = useState(true); - const prevQueryRef = useRef(autocompleteQueryValue); + const prevQueryRef = useRef(effectiveInputQueryValue); const innerListRef = useRef(null); const hasSetInitialFocusRef = useRef(false); @@ -264,11 +270,11 @@ function SearchAutocompleteList({ return; } - const queryChanged = prevQueryRef.current !== autocompleteQueryValue; - prevQueryRef.current = autocompleteQueryValue; + const queryChanged = prevQueryRef.current !== effectiveInputQueryValue; + prevQueryRef.current = effectiveInputQueryValue; if (queryChanged) { - if (autocompleteQueryValue === '') { + if (effectiveInputQueryValue === '') { // When query is cleared, reset the initial focus guard so the initial focus // effect can re-fire and correctly focus the first focusable item (skipping section headers). hasSetInitialFocusRef.current = false; @@ -278,7 +284,7 @@ function SearchAutocompleteList({ innerListRef.current?.updateAndScrollToFocusedIndex(0, true); } } - }, [autocompleteQueryValue, isInitialRender]); + }, [effectiveInputQueryValue, isInitialRender]); // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { @@ -296,7 +302,7 @@ function SearchAutocompleteList({ // Note: We can't easily subscribe to focus/blur events on the ref, so we update on query changes // which happen when the user types (meaning input is focused) - }, [textInputRef, autocompleteQueryValue]); + }, [textInputRef, effectiveInputQueryValue]); const autocompleteSuggestions = useAutocompleteSuggestions({ autocompleteQueryValue, @@ -366,10 +372,15 @@ function SearchAutocompleteList({ ]); const recentReportsOptions = useMemo(() => { - if (autocompleteQueryValue.trim() === '') { + if (!hasEffectiveInputQuery) { return searchOptions.recentReports; } + // User typed but debounce has not emitted yet; avoid showing stale results from the previous query. + if (isInputAheadOfDebounce) { + return []; + } + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -381,7 +392,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, isInputAheadOfDebounce, searchOptions]); // Locked rank map (keyForList -> originalIndex) capturing the order of locally-known // results at the moment the query changes. Recomputed only when the query changes, so server @@ -412,6 +423,9 @@ function SearchAutocompleteList({ setFrozenLocalRank(buildRankMap(recentReportsOptions)); } + // Debounce the server search so callers that don't already debounce upstream + // (e.g. the main Spend page header via useSearchPageInput) don't fire a request per keystroke. + // For SearchRouter the upstream value is already debounced, so this just adds a no-op coalescing layer. const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; @@ -454,7 +468,7 @@ function SearchAutocompleteList({ } } - if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { + if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); } @@ -487,7 +501,7 @@ function SearchAutocompleteList({ /> ); - if (autocompleteQueryValue.trim() === '') { + if (!hasEffectiveInputQuery) { // Empty query: single "Recent chats" section if (!isLoadingOptions) { pushSection({title: translate('search.recentChats'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); @@ -536,7 +550,7 @@ function SearchAutocompleteList({ } } - if (autocompleteSuggestions.length > 0) { + if (!isInputAheadOfDebounce && autocompleteSuggestions.length > 0) { const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { return { text: getAutocompleteDisplayText(filterKey, text), @@ -554,7 +568,8 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ - autocompleteQueryValue, + hasEffectiveInputQuery, + isInputAheadOfDebounce, autocompleteSuggestions, expensifyIcons, frozenLocalRank, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index db8a64bc421e..5982fa174151 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -82,10 +82,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const initialQuery = peekPendingRouterQuery(); - // The actual input text that the user sees - const [textInputValue, , setTextInputValue] = useDebouncedState(initialQuery, 500); - // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys - const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(initialQuery); + const [textInputValue, setTextInputValue] = useState(initialQuery); + // Debounced value gates expensive filtering in the autocomplete list + const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState(initialQuery, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: initialQuery.length, end: initialQuery.length}); useEffect(() => { @@ -263,7 +262,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setAutocompleteSubstitutions(updatedSubstitutionsMap); } }, - [autocompleteSubstitutions, setTextInputValue, textInputValue], + [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], ); const submitSearch = useCallback( @@ -286,7 +285,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setTextInputValue, setShouldResetSearchQuery], + [autocompleteSubstitutions, currentUserAccountID, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery], ); const onListItemPress = useCallback( @@ -399,6 +398,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla onSearchQueryChange={onSearchQueryChange} onSubmit={() => { const focusedOption = listRef.current?.getFocusedOption?.(); + const isInputAheadOfDebounce = !!textInputValue && textInputValue !== debouncedAutocompleteQueryValue; + + // During the debounce window, keep keyboard behavior for focused search rows + // (e.g. Ask Concierge / typed query row), but avoid stale non-search row submits. + if (isInputAheadOfDebounce) { + if (focusedOption && isSearchQueryItem(focusedOption)) { + onListItemPress(focusedOption); + return; + } + submitSearch(textInputValue); + return; + } if (!focusedOption) { submitSearch(textInputValue); @@ -419,7 +430,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> (), isReportChatRoom = false): boolean { - const searchWords = new Set(searchValue.replaceAll(',', ' ').split(/\s+/)); + const searchWords = Array.from(new Set(searchValue.replaceAll(',', ' ').split(/\s+/).filter(Boolean))); const valueToSearch = searchText?.replaceAll(new RegExp(/ /g), ''); - let matching = true; - for (const word of searchWords) { - // if one of the word is not matching, we don't need to check further - if (!matching) { - continue; + const compiledRegexes = searchWords.map((word) => ({word, regex: new RegExp(Str.escapeForRegExp(word), 'i')})); + for (const {word, regex} of compiledRegexes) { + if (!(regex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)))) { + return false; } - const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)); } - return matching; + return true; } function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 041736cbbc1e..79c778fdb1b5 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -1,5 +1,6 @@ import {rand} from '@ngneat/falso'; import type * as NativeNavigation from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; @@ -8,6 +9,7 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy} from '@src/types/onyx'; +import type Login from '@src/types/onyx/Login'; import type Report from '@src/types/onyx/Report'; import {formatSectionsFromSearchTerm} from '../../src/libs/OptionsListUtils'; import createCollection from '../utils/collections/createCollection'; @@ -19,6 +21,9 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const REPORTS_COUNT = 5000; const PERSONAL_DETAILS_LIST_COUNT = 1000; +// Larger dataset used specifically to measure the isSearchStringMatch RegExp optimization +const LARGE_REPORTS_COUNT = 40000; +const LARGE_PERSONAL_DETAILS_COUNT = 5000; const SEARCH_VALUE = 'Report'; const COUNTRY_CODE = 1; @@ -106,7 +111,7 @@ const ValidOptionsConfig = { sortedActions: undefined, }; -const loginList = {}; +const loginList: OnyxEntry = {}; /* GetOption is the private function and is never called directly, we are testing the functions which call getOption with different params */ describe('OptionsListUtils', () => { @@ -289,6 +294,30 @@ describe('OptionsListUtils', () => { ); }); + // This test directly measures the isSearchStringMatch hot path. + // A multi-word query forces one RegExp creation per word per item on main; + // on the PR branch RegExps are compiled once per call, so duration drops significantly. + test('[OptionsListUtils] filterAndOrderOptions with multi-word search on large dataset', async () => { + const largePersonalDetails = getMockedPersonalDetails(LARGE_PERSONAL_DETAILS_COUNT); + const largeReports = getMockedReports(LARGE_REPORTS_COUNT); + const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); + + const {options: formattedOptions} = getValidOptions( + {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, + allPolicies, + {}, + loginList, + MOCK_CURRENT_USER_ACCOUNT_ID, + MOCK_CURRENT_USER_EMAIL, + undefined, + ValidOptionsConfig, + ); + + await measureFunction(() => { + filterAndOrderOptions(formattedOptions, 'Email Report Five', COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, largePersonalDetails); + }); + }); + test('[OptionsListUtils] getSearchOptions with isSearching is true', async () => { await waitForBatchedUpdates(); const optionLists = createFilteredOptionList(personalDetails, mockedReportsMap, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 02cc35215ed5..493089cf801b 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -190,3 +190,28 @@ test('[SearchRouter] should react to text input changes', async () => { ) .then(() => measureRenders(, {scenario})); }); + +test('[SearchRouter] should re-render minimally when typing into the full router with autocomplete list', async () => { + const scenario = async () => { + const input = await screen.findByTestId('search-autocomplete-text-input'); + fireEvent.changeText(input, 'R'); + fireEvent.changeText(input, 'Re'); + fireEvent.changeText(input, 'Rep'); + fireEvent.changeText(input, 'Repo'); + fireEvent.changeText(input, 'Report'); + fireEvent.changeText(input, 'Report F'); + fireEvent.changeText(input, 'Report Fi'); + fireEvent.changeText(input, 'Report Five'); + }; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: true, + }), + ) + .then(() => measureRenders(, {scenario})); +}); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index bf2f3ac60865..b4b74c126c17 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -80,6 +80,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type Login from '@src/types/onyx/Login'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport, createRegularChat} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; @@ -636,7 +637,7 @@ describe('OptionsListUtils', () => { }, ]; - const loginList = {}; + const loginList: OnyxEntry = {}; const CURRENT_USER_ACCOUNT_ID = 2; const CURRENT_USER_EMAIL = 'tonystark@expensify.com'; @@ -3250,6 +3251,28 @@ describe('OptionsListUtils', () => { // Then the self dm should be on top. expect(filteredOptions.recentReports.at(0)?.isSelfDM).toBe(true); }); + + it('should return the same matches for normalized multi-word queries with extra spaces', () => { + const {options} = getSearchOptions({ + options: OPTIONS, + reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, + draftComments: {}, + loginList, + betas: [CONST.BETAS.ALL], + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: CURRENT_USER_EMAIL, + policyCollection: allPolicies, + personalDetails: PERSONAL_DETAILS, + sortedActions: undefined, + conciergeReportID: undefined, + }); + + const multiSpaceQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const spaceSeparatedQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + + expect(multiSpaceQueryResults.recentReports.map((option) => option.reportID)).toEqual(spaceSeparatedQueryResults.recentReports.map((option) => option.reportID)); + expect(multiSpaceQueryResults.personalDetails.map((option) => option.accountID)).toEqual(spaceSeparatedQueryResults.personalDetails.map((option) => option.accountID)); + }); }); describe('canCreateOptimisticPersonalDetailOption()', () => {