Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e676af6
82307: debounce autocomplete query and memoize search options
abbasifaizan70 Apr 1, 2026
3bbdbca
82307: debounce autocomplete query and memoize search options
abbasifaizan70 Apr 1, 2026
4a55f1a
fixed eslint issues
abbasifaizan70 Apr 1, 2026
9b669fe
fixed eslint issues
abbasifaizan70 Apr 1, 2026
11e34d5
fixed eslint issues
abbasifaizan70 Apr 1, 2026
0c69c3e
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 1, 2026
3e4067c
fixed lint issue
abbasifaizan70 Apr 1, 2026
4e8ba5a
Merge branch '83207' of https://github.com/abbasifaizan70/Expensify i…
abbasifaizan70 Apr 1, 2026
c4c2f9a
fixed AI feedback
abbasifaizan70 Apr 2, 2026
0648ce9
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 2, 2026
8e65cd2
Fixed eslint warnings
abbasifaizan70 Apr 2, 2026
8d71e2c
Merge branch '83207' of https://github.com/abbasifaizan70/Expensify i…
abbasifaizan70 Apr 2, 2026
e1d2ccc
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 7, 2026
81ffc89
Added unit test cases coverage for search matching normalization
abbasifaizan70 Apr 8, 2026
c447ddc
Updated SearchRouter to prevent raw input from bypassing debounce
abbasifaizan70 Apr 8, 2026
e5e514d
Fixed prettier issues
abbasifaizan70 Apr 8, 2026
4bbbec9
Fixed AI feedbacks
abbasifaizan70 Apr 8, 2026
429042f
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 8, 2026
e66d380
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 8, 2026
f246760
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 8, 2026
59bbc7d
Merge branch 'Expensify:main' into 83207
abbasifaizan70 Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubstitutionMap>({});
const textInputRef = useRef<AnimatedTextInputRef>(null);
Expand Down Expand Up @@ -215,7 +215,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
setAutocompleteSubstitutions(updatedSubstitutionsMap);
}
},
[autocompleteSubstitutions, setTextInputValue, textInputValue],
[autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue],
);

const submitSearch = useCallback(
Expand All @@ -238,7 +238,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
setTextInputValue('');
setAutocompleteQueryValue('');
},
[autocompleteSubstitutions, onRouterClose, setTextInputValue, setShouldResetSearchQuery],
[autocompleteSubstitutions, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery],
);

const onListItemPress = useCallback(
Expand Down Expand Up @@ -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) {
Expand All @@ -347,7 +354,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
/>
</View>
<DeferredAutocompleteList
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
autocompleteQueryValue={textInputValue === '' ? '' : debouncedAutocompleteQueryValue}
handleSearch={searchInServer}
searchQueryItem={searchQueryItem}
getAdditionalSections={getAdditionalSections}
Expand Down
15 changes: 6 additions & 9 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
*/

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 217 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -230,7 +230,7 @@
const deprecatedCachedOneTransactionThreadReportIDs: Record<string, string | undefined> = {};
/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */
let deprecatedAllReportActions: OnyxCollection<ReportActions>;
Onyx.connect({

Check warning on line 233 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand Down Expand Up @@ -280,7 +280,7 @@
});

let activePolicyID: OnyxEntry<string>;
Onyx.connect({

Check warning on line 283 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
callback: (value) => (activePolicyID = value),
});
Expand Down Expand Up @@ -507,18 +507,15 @@
* Searches for a match when provided with a value
*/
function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set<string>(), isReportChatRoom = false): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please explain changes in this function?
What's this refactor for? Does this improve performance?
And add unit tests

Copy link
Copy Markdown
Contributor Author

@abbasifaizan70 abbasifaizan70 Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aimane-chnaif This change is mainly a perf cleanup in a hot path, not intended to change behavior. Before, isSearchStringMatch() was creating a new RegExp inside the loop for every search word. Since this function runs across lots of options while typing, that adds unnecessary work on each keystroke.

What I changed:

  • normalize/dedupe the words once
  • compile the regexes once per function call
  • reuse them in the loop
  • return early on first mismatch

So matching logic stays the same, but we avoid repeated regex allocations and reduce JS work during search filtering.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, please add unit test (not perf-test) for this function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aimane-chnaif Added unit test in OptionsListUtilsTest.tsx covering the refactored isSearchStringMatch path (multi-word normalization behavior), and it passes.

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(/&nbsp;/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) {
Expand Down
27 changes: 27 additions & 0 deletions tests/perf-test/OptionsListUtils.perf-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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, {
Expand Down
25 changes: 25 additions & 0 deletions tests/perf-test/SearchRouter.perf-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,28 @@ test('[SearchRouter] should react to text input changes', async () => {
)
.then(() => measureRenders(<SearchAutocompleteInputWrapper />, {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(<SearchRouterWrapperWithCachedOptions />, {scenario}));
});
21 changes: 21 additions & 0 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
Loading