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/19] 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 e504a7b08b6d..486c06beb44b 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 94f53cad8c9e..59accbefc274 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 07e9c0cb3f96..bbc3efee6d96 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 01d60b954aa6..5e3c1d58088b 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/19] 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 e504a7b08b6d..486c06beb44b 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 94f53cad8c9e..59accbefc274 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 07e9c0cb3f96..4b3fe60e2323 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 01d60b954aa6..5e3c1d58088b 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/19] 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 59accbefc274..6e9d0d67b97c 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 5e3c1d58088b..610ddbc629f7 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/19] 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 6e9d0d67b97c..59accbefc274 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 610ddbc629f7..3779189a34c0 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/19] 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 59accbefc274..6e9d0d67b97c 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/19] 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 6e9d0d67b97c..0a8ebd6cba41 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/19] 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 486c06beb44b..e504a7b08b6d 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 0a8ebd6cba41..b9cb30125288 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/19] 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 79a7807d2506..8ded912eb7eb 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/19] 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 51448498b3c8..00d353204bbe 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/19] 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 8ded912eb7eb..8f01b222c5b1 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/19] 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 00d353204bbe..dfe4f37a92ba 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) { From 500aec86b1806e530abadbf6ac7f83bf785f55e4 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Tue, 21 Apr 2026 22:59:09 +0500 Subject: [PATCH 12/19] Fixed types issue --- tests/unit/OptionsListUtilsTest.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 3fc48a533fc5..c560aafcc74a 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3488,6 +3488,8 @@ describe('OptionsListUtils', () => { 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); From a1c33cb13960ef8952c9552b51e5154f0a5858a4 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 20:08:25 +0500 Subject: [PATCH 13/19] Fixed recent search flicker issue --- .../Search/SearchAutocompleteList.tsx | 35 ++++++++++++------- .../Search/SearchRouter/SearchRouter.tsx | 1 + 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 23bd7dfb9bf4..1b72ed121732 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -51,6 +51,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; @@ -130,6 +132,7 @@ function SearchRouterItem(props: UserListItemProps | Searc function SearchAutocompleteList({ autocompleteQueryValue, + inputQueryValue, handleSearch, searchQueryItem, getAdditionalSections, @@ -161,6 +164,8 @@ 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 hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -235,7 +240,7 @@ function SearchAutocompleteList({ ]); const [isInitialRender, setIsInitialRender] = useState(true); - const prevQueryRef = useRef(autocompleteQueryValue); + const prevQueryRef = useRef(effectiveInputQueryValue); const innerListRef = useRef(null); const hasSetInitialFocusRef = useRef(false); @@ -257,11 +262,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; @@ -271,7 +276,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(() => { @@ -289,7 +294,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, @@ -360,10 +365,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 empty-query results. + if (autocompleteQueryValue.trim() === '') { + return []; + } + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -375,7 +385,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -419,7 +429,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++}); } @@ -441,18 +451,18 @@ function SearchAutocompleteList({ if (!isLoadingOptions) { pushSection({ - title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, + title: !hasEffectiveInputQuery ? translate('search.recentChats') : undefined, data: nextStyledRecentReports, sectionIndex: sectionIndex++, }); - } else if (autocompleteQueryValue.trim() !== '' && nextStyledRecentReports.length > 0) { + } else if (hasEffectiveInputQuery && nextStyledRecentReports.length > 0) { // When options aren't fully initialized but we have a search query with available results, // render them immediately so they're selectable instead of hiding the section entirely. pushSection({ data: nextStyledRecentReports, sectionIndex: sectionIndex++, }); - } else if (autocompleteQueryValue.trim() === '') { + } else if (!hasEffectiveInputQuery) { pushSection({ title: translate('search.recentChats'), data: [], @@ -491,6 +501,7 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ autocompleteQueryValue, + hasEffectiveInputQuery, autocompleteSuggestions, expensifyIcons, getAdditionalSections, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index e4ed960d36c3..11a96bf54ef6 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -392,6 +392,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla Date: Wed, 6 May 2026 20:36:21 +0500 Subject: [PATCH 14/19] Fixed Typesrcipt issues --- src/components/Search/SearchAutocompleteList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 1b72ed121732..79e65cae80eb 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -500,7 +500,6 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ - autocompleteQueryValue, hasEffectiveInputQuery, autocompleteSuggestions, expensifyIcons, From 478dcf2fa3632f5e00d59cec809a43aa19a24a84 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 21:06:16 +0500 Subject: [PATCH 15/19] fixed eslint issues --- tests/perf-test/OptionsListUtils.perf-test.ts | 5 +++-- tests/unit/OptionsListUtilsTest.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 5b86b61b8f80..b5ebcdf34109 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'; @@ -109,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', () => { @@ -304,7 +306,6 @@ describe('OptionsListUtils', () => { {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, allPolicies, {}, - nvpDismissedProductTraining, loginList, MOCK_CURRENT_USER_ACCOUNT_ID, MOCK_CURRENT_USER_EMAIL, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 0b9aeac50861..718301d703a5 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -77,6 +77,7 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; +import type Login from '@src/types/onyx/Login'; 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'; @@ -662,7 +663,7 @@ describe('OptionsListUtils', () => { }, ]; - const loginList = {}; + const loginList: OnyxEntry = {}; const CURRENT_USER_ACCOUNT_ID = 2; const CURRENT_USER_EMAIL = 'tonystark@expensify.com'; @@ -3302,7 +3303,6 @@ describe('OptionsListUtils', () => { options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {}, - nvpDismissedProductTraining, loginList, betas: [CONST.BETAS.ALL], currentUserAccountID: CURRENT_USER_ACCOUNT_ID, From b333029070d11692ab66768296e1075af2a6fbeb Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 21:27:31 +0500 Subject: [PATCH 16/19] Fixed prettier issues --- tests/unit/OptionsListUtilsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 718301d703a5..14f2b7911bad 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -77,10 +77,10 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type Login from '@src/types/onyx/Login'; 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'; From f434f434ac3b1ff9161754e42806251e5eb2ee02 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 7 May 2026 14:01:19 +0500 Subject: [PATCH 17/19] Fixed Feedbacks --- src/components/Search/SearchAutocompleteList.tsx | 9 ++------- .../Search/SearchRouter/SearchRouter.tsx | 15 ++++++++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index cd9729181031..f340872d43f4 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -10,7 +10,6 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDebounce from '@hooks/useDebounce'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -395,17 +394,13 @@ function SearchAutocompleteList({ return reportOptions.slice(0, 20); }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); - const debounceHandleSearch = useDebounce(() => { + useEffect(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; } handleSearch(autocompleteQueryWithoutFilters); - }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - - useEffect(() => { - debounceHandleSearch(); - }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); + }, [autocompleteQueryWithoutFilters, handleSearch]); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 25621ae835a8..65e5e52cf70a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -384,15 +384,20 @@ 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) { + 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; } - const focusedOption = listRef.current?.getFocusedOption?.(); - if (!focusedOption) { submitSearch(textInputValue); return; From 7d6479a206704b69f4e754d8bb0548b34e8d4af9 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Fri, 15 May 2026 17:22:44 +0500 Subject: [PATCH 18/19] fixed AI feedbacks --- src/components/Search/SearchAutocompleteList.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index f340872d43f4..245328bdaf9c 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -175,6 +175,7 @@ function SearchAutocompleteList({ 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 ?? ''; @@ -376,8 +377,8 @@ function SearchAutocompleteList({ return searchOptions.recentReports; } - // User typed but debounce has not emitted yet; avoid showing stale empty-query results. - if (autocompleteQueryValue.trim() === '') { + // User typed but debounce has not emitted yet; avoid showing stale results from the previous query. + if (isInputAheadOfDebounce) { return []; } @@ -392,7 +393,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, isInputAheadOfDebounce, searchOptions]); useEffect(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -485,7 +486,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), @@ -504,6 +505,7 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ hasEffectiveInputQuery, + isInputAheadOfDebounce, autocompleteSuggestions, expensifyIcons, getAdditionalSections, From b5f476a68ef38667256d520127fa1f664ea5704c Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 10 Jun 2026 23:05:13 +0500 Subject: [PATCH 19/19] Fixed feedbacks and CI issues --- src/components/Search/SearchAutocompleteList.tsx | 12 ++++++++++-- tests/perf-test/OptionsListUtils.perf-test.ts | 2 +- tests/unit/OptionsListUtilsTest.tsx | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 0c854a0149ba..b6baa2f14cb5 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -10,6 +10,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebounce from '@hooks/useDebounce'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -422,13 +423,20 @@ function SearchAutocompleteList({ setFrozenLocalRank(buildRankMap(recentReportsOptions)); } - useEffect(() => { + // 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; } handleSearch(autocompleteQueryWithoutFilters); - }, [autocompleteQueryWithoutFilters, handleSearch]); + }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + + useEffect(() => { + debounceHandleSearch(); + }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 4f75cd1b6800..79c778fdb1b5 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -302,7 +302,7 @@ describe('OptionsListUtils', () => { const largeReports = getMockedReports(LARGE_REPORTS_COUNT); const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); - const formattedOptions = getValidOptions( + const {options: formattedOptions} = getValidOptions( {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, allPolicies, {}, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 574bb94f36e7..b4b74c126c17 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3253,7 +3253,7 @@ describe('OptionsListUtils', () => { }); it('should return the same matches for normalized multi-word queries with extra spaces', () => { - const options = getSearchOptions({ + const {options} = getSearchOptions({ options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {},