diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index a3d8f2e2d08ff..dfe4f37a92ba2 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -76,9 +76,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']); // The actual input text that the user sees - const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys - const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); + const [textInputValue, setTextInputValue] = useState(''); + // Debounced value gates expensive filtering in the autocomplete list + const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); @@ -215,7 +215,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setAutocompleteSubstitutions(updatedSubstitutionsMap); } }, - [autocompleteSubstitutions, setTextInputValue, textInputValue], + [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], ); const submitSearch = useCallback( @@ -238,7 +238,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, onRouterClose, setTextInputValue, setShouldResetSearchQuery], + [autocompleteSubstitutions, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery], ); const onListItemPress = useCallback( @@ -326,6 +326,13 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla isFullWidth={shouldUseNarrowLayout} onSearchQueryChange={onSearchQueryChange} onSubmit={() => { + // If user submits before debounce catches up, submit the typed query directly + // instead of selecting a stale focused list item from the previous query. + if (textInputValue && textInputValue !== debouncedAutocompleteQueryValue) { + submitSearch(textInputValue); + return; + } + const focusedOption = listRef.current?.getFocusedOption?.(); if (!focusedOption) { @@ -347,7 +354,7 @@ 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 07e9c0cb3f963..4b3fe60e23231 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -20,6 +20,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; @@ -289,6 +292,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 formattedOptions = getValidOptions( + {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + MOCK_CURRENT_USER_ACCOUNT_ID, + MOCK_CURRENT_USER_EMAIL, + 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 searchTerm', 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 01d60b954aa61..3779189a34c09 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 e9d066817d968..920f04b358273 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3231,6 +3231,27 @@ 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: {}, + nvpDismissedProductTraining, + loginList, + betas: [CONST.BETAS.ALL], + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: CURRENT_USER_EMAIL, + policyCollection: allPolicies, + personalDetails: PERSONAL_DETAILS, + }); + + 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()', () => {