From e676af69a08b0f7d214eb6fb45229ec94b5dad8d Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 03:06:48 +0500 Subject: [PATCH 01/11] 82307: debounce autocomplete query and memoize search options --- .../Search/SearchAutocompleteList.tsx | 22 ++++++++++++--- .../Search/SearchRouter/SearchRouter.tsx | 6 ++--- src/libs/OptionsListUtils/index.ts | 15 +++++------ tests/perf-test/OptionsListUtils.perf-test.ts | 27 +++++++++++++++++++ tests/perf-test/SearchRouter.perf-test.tsx | 25 +++++++++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e504a7b08b6d6..486c06beb44b8 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,7 +214,21 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - })(); + }, [ + areOptionsInitialized, + options, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -324,7 +338,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -340,7 +354,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 94f53cad8c9e1..59accbefc274c 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // 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); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, 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); @@ -348,7 +348,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..bbc3efee6d969 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 = 10000; +const LARGE_PERSONAL_DETAILS_COUNT = 2000; 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..5e3c1d58088b0 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, 'E'); + fireEvent.changeText(input, 'Em'); + fireEvent.changeText(input, 'Ema'); + fireEvent.changeText(input, 'Emai'); + fireEvent.changeText(input, 'Email'); + fireEvent.changeText(input, 'Email F'); + fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email 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})); +}); From 3bbdbca4fb7b621d174ad85b0beaaca8afb31f34 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 03:26:59 +0500 Subject: [PATCH 02/11] 82307: debounce autocomplete query and memoize search options --- .../Search/SearchAutocompleteList.tsx | 22 ++++++++++++--- .../Search/SearchRouter/SearchRouter.tsx | 6 ++--- src/libs/OptionsListUtils/index.ts | 15 +++++------ tests/perf-test/OptionsListUtils.perf-test.ts | 27 +++++++++++++++++++ tests/perf-test/SearchRouter.perf-test.tsx | 25 +++++++++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e504a7b08b6d6..486c06beb44b8 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,7 +214,21 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - })(); + }, [ + areOptionsInitialized, + options, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -324,7 +338,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -340,7 +354,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 94f53cad8c9e1..59accbefc274c 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // 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); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, 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); @@ -348,7 +348,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..5e3c1d58088b0 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, 'E'); + fireEvent.changeText(input, 'Em'); + fireEvent.changeText(input, 'Ema'); + fireEvent.changeText(input, 'Emai'); + fireEvent.changeText(input, 'Email'); + fireEvent.changeText(input, 'Email F'); + fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email 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})); +}); From 4a55f1a91bd5a581e4e5e7e88e3d51e412d19146 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:19:21 +0500 Subject: [PATCH 03/11] fixed eslint issues --- src/components/Search/SearchRouter/SearchRouter.tsx | 4 ++-- tests/perf-test/SearchRouter.perf-test.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 59accbefc274c..6e9d0d67b97cc 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list - const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // 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); diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 5e3c1d58088b0..610ddbc629f7a 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -194,14 +194,14 @@ test('[SearchRouter] should react to text input changes', async () => { 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, 'E'); - fireEvent.changeText(input, 'Em'); - fireEvent.changeText(input, 'Ema'); - fireEvent.changeText(input, 'Emai'); fireEvent.changeText(input, 'Email'); - fireEvent.changeText(input, 'Email F'); - fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email Four'); + fireEvent.changeText(input, 'Email'); fireEvent.changeText(input, 'Email Five'); + fireEvent.changeText(input, 'Report'); + fireEvent.changeText(input, 'Report One'); + fireEvent.changeText(input, 'Report'); + fireEvent.changeText(input, 'Report Two'); }; return waitForBatchedUpdates() From 9b669fefa2f454f633940f42a83191e0d7524023 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:24:42 +0500 Subject: [PATCH 04/11] fixed eslint issues --- .../Search/SearchRouter/SearchRouter.tsx | 4 ++-- tests/perf-test/SearchRouter.perf-test.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6e9d0d67b97cc..59accbefc274c 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Debounced value gates expensive filtering in the autocomplete list - const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, 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); diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 610ddbc629f7a..3779189a34c09 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -194,14 +194,14 @@ test('[SearchRouter] should react to text input changes', async () => { 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, 'Email'); - fireEvent.changeText(input, 'Email Four'); - fireEvent.changeText(input, 'Email'); - fireEvent.changeText(input, 'Email Five'); - fireEvent.changeText(input, 'Report'); - fireEvent.changeText(input, 'Report One'); + fireEvent.changeText(input, 'R'); + fireEvent.changeText(input, 'Re'); + fireEvent.changeText(input, 'Rep'); + fireEvent.changeText(input, 'Repo'); fireEvent.changeText(input, 'Report'); - fireEvent.changeText(input, 'Report Two'); + fireEvent.changeText(input, 'Report F'); + fireEvent.changeText(input, 'Report Fi'); + fireEvent.changeText(input, 'Report Five'); }; return waitForBatchedUpdates() From 3e4067cd062e528995a0eb148334f20b40e71fa3 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:42:30 +0500 Subject: [PATCH 05/11] fixed lint issue --- src/components/Search/SearchRouter/SearchRouter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 59accbefc274c..6e9d0d67b97cc 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list - const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // 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); From c4c2f9ada7d101da5549785f0eabb0bac97fce43 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 05:07:05 +0500 Subject: [PATCH 06/11] fixed AI feedback --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6e9d0d67b97cc..0a8ebd6cba417 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -348,7 +348,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> Date: Fri, 3 Apr 2026 00:44:53 +0500 Subject: [PATCH 07/11] Fixed eslint warnings --- .../Search/SearchAutocompleteList.tsx | 22 ++++--------------- .../Search/SearchRouter/SearchRouter.tsx | 4 ++-- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 486c06beb44b8..e504a7b08b6d6 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = useMemo(() => { + const searchOptions = (() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,21 +214,7 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - }, [ - areOptionsInitialized, - options, - draftComments, - nvpDismissedProductTraining, - betas, - autocompleteQueryValue, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - policies, - personalDetails, - ]); + })(); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -338,7 +324,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = useMemo(() => { + const recentReportsOptions = (() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -354,7 +340,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + })(); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 0a8ebd6cba417..b9cb301252889 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -216,7 +216,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setAutocompleteSubstitutions(updatedSubstitutionsMap); } }, - [autocompleteSubstitutions, setTextInputValue, textInputValue], + [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], ); const submitSearch = useCallback( @@ -239,7 +239,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, onRouterClose, setTextInputValue, setShouldResetSearchQuery], + [autocompleteSubstitutions, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery], ); const onListItemPress = useCallback( From 81ffc89d887230935ce586a7ad6e49740f6e7465 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 8 Apr 2026 23:12:32 +0500 Subject: [PATCH 08/11] Added unit test cases coverage for search matching normalization --- tests/unit/OptionsListUtilsTest.tsx | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 79a7807d25064..8ded912eb7eb4 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3231,6 +3231,47 @@ 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()', () => { From c447ddce2761677d1855258ce81a1a79943ae4a6 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 8 Apr 2026 23:23:06 +0500 Subject: [PATCH 09/11] Updated SearchRouter to prevent raw input from bypassing debounce --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 51448498b3c87..00d353204bbe1 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -347,7 +347,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> Date: Wed, 8 Apr 2026 23:25:25 +0500 Subject: [PATCH 10/11] Fixed prettier issues --- tests/unit/OptionsListUtilsTest.tsx | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 8ded912eb7eb4..8f01b222c5b19 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3246,31 +3246,11 @@ describe('OptionsListUtils', () => { 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, - ); + 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), - ); + 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)); }); }); From 4bbbec926f10d32ebb39931af9060c948fdace86 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 9 Apr 2026 02:13:57 +0500 Subject: [PATCH 11/11] Fixed AI feedbacks --- src/components/Search/SearchRouter/SearchRouter.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 00d353204bbe1..dfe4f37a92ba2 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -76,7 +76,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']); // The actual input text that the user sees - const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); + 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}); @@ -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) {