-
Notifications
You must be signed in to change notification settings - Fork 4k
Optimize Search functionality by filtering and slicing options list in getValidOptions #65810
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mountiny
merged 23 commits into
Expensify:main
from
callstack-internal:perf/search-optimization
Jul 28, 2025
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
b95e3f0
Optimize Search functionality by filtering and slicing options list
sosek108 3d618f1
set maxResults as undefined for back compability
sosek108 4700f23
Merge branch 'main' into perf/search-optimization
sosek108 8db4875
include user to invite
sosek108 b011a3f
Cleanup code, final improvements, heap usage in autocomplete keys (in,
sosek108 72eb17f
Additional comments
sosek108 27543ac
Merge branch 'main' into perf/search-optimization
sosek108 90e1e6f
Merge branch 'main' into perf/search-optimization
sosek108 64455d9
Merge branch 'main' into perf/search-optimization
sosek108 04c13c0
fixes according to comments
sosek108 97a7b53
add func doc comments
sosek108 d21e783
Adjust logic to handle proper ordering of personal details
sosek108 c1dc777
Prettier fix
sosek108 ae5ba79
Update report filtering to match maxResults
sosek108 f640e2c
eslint fix
sosek108 648529d
Merge branch 'main' into perf/search-optimization
sosek108 471e5b7
Update src/libs/OptionsListUtils.ts
sosek108 ea88dd1
Add MaxHeap implementation and update heap selection logic
sosek108 3952e8f
Prettier fix
sosek108 f7ca5df
Update searchText for personalDetails
sosek108 5bd0bfc
Update sorting comparator for personalDetails
sosek108 0b58349
Searching for nonlatin characters
sosek108 7985588
Merge branch 'main' into perf/search-optimization
kacper-mikolajczak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,3 @@ | ||
| import {Str} from 'expensify-common'; | ||
| import type {ForwardedRef} from 'react'; | ||
| import React, {forwardRef, useCallback, useEffect, useMemo, useState} from 'react'; | ||
| import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; | ||
|
|
@@ -11,17 +10,15 @@ import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/ | |
| import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; | ||
| import UserListItem from '@components/SelectionList/UserListItem'; | ||
| import useDebounce from '@hooks/useDebounce'; | ||
| import useFastSearchFromOptions from '@hooks/useFastSearchFromOptions'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import useOnyx from '@hooks/useOnyx'; | ||
| import useResponsiveLayout from '@hooks/useResponsiveLayout'; | ||
| import useThemeStyles from '@hooks/useThemeStyles'; | ||
| import {getCardFeedsForDisplay} from '@libs/CardFeedUtils'; | ||
| import {getCardDescription, isCard, isCardHiddenFromSearch} from '@libs/CardUtils'; | ||
| import Log from '@libs/Log'; | ||
| import memoize from '@libs/memoize'; | ||
| import type {Options, SearchOption} from '@libs/OptionsListUtils'; | ||
| import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions, getValidPersonalDetailOptions, optionsOrderBy, recentReportComparator} from '@libs/OptionsListUtils'; | ||
| import type {Options} from '@libs/OptionsListUtils'; | ||
| import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions} from '@libs/OptionsListUtils'; | ||
| import Performance from '@libs/Performance'; | ||
| import {getAllTaxRates, getCleanedTagName, shouldShowPolicy} from '@libs/PolicyUtils'; | ||
| import type {OptionData} from '@libs/ReportUtils'; | ||
|
|
@@ -40,7 +37,6 @@ import Timing from '@userActions/Timing'; | |
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import type {CardFeeds, CardList, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; | ||
| import type PersonalDetails from '@src/types/onyx/PersonalDetails'; | ||
| import {getEmptyObject} from '@src/types/utils/EmptyObject'; | ||
| import {getSubstitutionMapKey} from './SearchRouter/getQueryWithSubstitutions'; | ||
| import type {SearchFilterKey, UserFriendlyKey} from './types'; | ||
|
|
@@ -178,8 +174,8 @@ function SearchAutocompleteList( | |
| if (!areOptionsInitialized) { | ||
| return defaultListOptions; | ||
| } | ||
| return getSearchOptions(options, betas ?? []); | ||
| }, [areOptionsInitialized, betas, options]); | ||
| return getSearchOptions(options, betas ?? [], true, true, autocompleteQueryValue, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, true); | ||
| }, [areOptionsInitialized, betas, options, autocompleteQueryValue]); | ||
|
|
||
| const [isInitialRender, setIsInitialRender] = useState(true); | ||
|
|
||
|
|
@@ -217,38 +213,6 @@ function SearchAutocompleteList( | |
| return Object.values(getCardFeedsForDisplay(allFeeds, {})); | ||
| }, [allFeeds]); | ||
|
|
||
| const getParticipantsAutocompleteList = useMemo( | ||
| () => | ||
| memoize(() => { | ||
| if (!areOptionsInitialized) { | ||
| return []; | ||
| } | ||
|
|
||
| const currentUserRef = { | ||
| current: undefined as OptionData | undefined, | ||
| }; | ||
| const filteredOptions = getValidPersonalDetailOptions(options.personalDetails, { | ||
| loginsToExclude: CONST.EXPENSIFY_EMAILS_OBJECT, | ||
| shouldBoldTitleByDefault: false, | ||
| currentUserRef, | ||
| }); | ||
|
|
||
| // This cast is needed as something is incorrect in types OptionsListUtils.getOptions around l1490 and includeRecentReports types | ||
| const personalDetailsFromOptions = filteredOptions.map((option) => (option as SearchOption<PersonalDetails>).item); | ||
| const autocompleteOptions = Object.values(personalDetailsFromOptions) | ||
| .filter((details): details is NonNullable<PersonalDetails> => !!details?.login) | ||
| .map((details) => { | ||
| return { | ||
| name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), | ||
| accountID: details.accountID.toString(), | ||
| }; | ||
| }); | ||
|
|
||
| return autocompleteOptions; | ||
| }), | ||
| [areOptionsInitialized, options.personalDetails], | ||
| ); | ||
|
|
||
| const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(taxRates), [taxRates]); | ||
|
|
||
| const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, {canBeMissing: false}); | ||
|
|
@@ -357,22 +321,21 @@ function SearchAutocompleteList( | |
| case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: | ||
| case CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER: | ||
| case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER: { | ||
| const filteredParticipants = getParticipantsAutocompleteList() | ||
| .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) | ||
| .slice(0, 10); | ||
| const participants = getSearchOptions(options, betas ?? [], true, true, autocompleteValue, 10, false, false).personalDetails.filter( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This changed caused the bug where current user option is missing. #67609. |
||
| (participant) => participant.text && !alreadyAutocompletedKeys.includes(participant.text.toLowerCase()), | ||
| ); | ||
|
|
||
| return filteredParticipants.map((participant) => ({ | ||
| return participants.map((participant) => ({ | ||
| filterKey: autocompleteKey, | ||
| text: participant.name, | ||
| autocompleteID: participant.accountID, | ||
| text: participant.text ?? '', | ||
| autocompleteID: String(participant.accountID), | ||
| mapKey: autocompleteKey, | ||
| })); | ||
| } | ||
| case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { | ||
| const filterChats = (chat: OptionData) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase()); | ||
| const filteredChats = optionsOrderBy(searchOptions.recentReports, 10, recentReportComparator, filterChats); | ||
| const filteredReports = getSearchOptions(options, betas ?? [], true, true, autocompleteValue, 10, false, true).recentReports; | ||
|
|
||
| return filteredChats.map((chat) => ({ | ||
| return filteredReports.map((chat) => ({ | ||
| filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN, | ||
| text: chat.text ?? '', | ||
| autocompleteID: chat.reportID, | ||
|
|
@@ -489,8 +452,8 @@ function SearchAutocompleteList( | |
| currencyAutocompleteList, | ||
| recentCurrencyAutocompleteList, | ||
| taxAutocompleteList, | ||
| getParticipantsAutocompleteList, | ||
| searchOptions.recentReports, | ||
| options, | ||
| betas, | ||
| typeAutocompleteList, | ||
| groupByAutocompleteList, | ||
| statusAutocompleteList, | ||
|
|
@@ -517,11 +480,6 @@ function SearchAutocompleteList( | |
| }; | ||
| }); | ||
|
|
||
| /** | ||
| * Builds a suffix tree and returns a function to search in it. | ||
| */ | ||
| const {search: filterOptions, isInitialized: isFastSearchInitialized} = useFastSearchFromOptions(searchOptions, {includeUserToInvite: true}); | ||
|
|
||
| const recentReportsOptions = useMemo(() => { | ||
| const actionId = `filter_options_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; | ||
| const startTime = Date.now(); | ||
|
|
@@ -532,37 +490,32 @@ function SearchAutocompleteList( | |
| actionId, | ||
| queryLength: autocompleteQueryValue.length, | ||
| queryTrimmed: autocompleteQueryValue.trim(), | ||
| isFastSearchInitialized, | ||
| recentReportsCount: searchOptions.recentReports.length, | ||
| timestamp: startTime, | ||
| }); | ||
|
|
||
| try { | ||
| if (autocompleteQueryValue.trim() === '' || !isFastSearchInitialized) { | ||
| const orderedReportOptions = optionsOrderBy(searchOptions.recentReports, 20, recentReportComparator); | ||
|
|
||
| if (autocompleteQueryValue.trim() === '') { | ||
| const endTime = Date.now(); | ||
| Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); | ||
| Performance.markEnd(CONST.TIMING.SEARCH_FILTER_OPTIONS); | ||
| Log.info('[CMD_K_DEBUG] Filter options completed (empty query path)', false, { | ||
| actionId, | ||
| duration: endTime - startTime, | ||
| resultCount: orderedReportOptions.length, | ||
| timestamp: endTime, | ||
| }); | ||
|
|
||
| return orderedReportOptions; | ||
| return searchOptions.recentReports; | ||
| } | ||
|
|
||
| const filteredOptions = filterOptions(autocompleteQueryValue); | ||
| const orderedOptions = combineOrderingOfReportsAndPersonalDetails(filteredOptions, autocompleteQueryValue, { | ||
| const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { | ||
| sortByReportTypeInSearch: true, | ||
| preferChatRoomsOverThreads: true, | ||
| }); | ||
|
|
||
| const reportOptions: OptionData[] = [...orderedOptions.recentReports, ...orderedOptions.personalDetails]; | ||
| if (filteredOptions.userToInvite) { | ||
| reportOptions.push(filteredOptions.userToInvite); | ||
| if (searchOptions.userToInvite) { | ||
| reportOptions.push(searchOptions.userToInvite); | ||
| } | ||
|
|
||
| const finalOptions = reportOptions.slice(0, 20); | ||
|
|
@@ -574,7 +527,7 @@ function SearchAutocompleteList( | |
| duration: endTime - startTime, | ||
| recentReportsFiltered: orderedOptions.recentReports.length, | ||
| personalDetailsFiltered: orderedOptions.personalDetails.length, | ||
| hasUserToInvite: !!filteredOptions.userToInvite, | ||
| hasUserToInvite: !!searchOptions.userToInvite, | ||
| finalResultCount: finalOptions.length, | ||
| timestamp: endTime, | ||
| }); | ||
|
|
@@ -593,7 +546,7 @@ function SearchAutocompleteList( | |
| }); | ||
| throw error; | ||
| } | ||
| }, [autocompleteQueryValue, filterOptions, searchOptions, isFastSearchInitialized]); | ||
| }, [autocompleteQueryValue, searchOptions]); | ||
|
|
||
| const debounceHandleSearch = useDebounce( | ||
| useCallback(() => { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import {Heap} from './Heap'; | ||
|
|
||
| type GetCompareValue<T> = (item: T) => number | string; | ||
|
|
||
| /** | ||
| * Comparison function for a max-heap based on the provided `getCompareValue` function. | ||
| * @param getCompareValue | ||
| */ | ||
| function getMaxCompare<T>(getCompareValue?: GetCompareValue<T>): (a: T, b: T) => number { | ||
| return (a, b) => { | ||
| const aVal = typeof getCompareValue === 'function' ? getCompareValue(a) : a; | ||
| const bVal = typeof getCompareValue === 'function' ? getCompareValue(b) : b; | ||
| return aVal >= bVal ? -1 : 1; | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * MaxHeap is a priority queue that always keeps the largest element at the top. | ||
| * Internally, it uses a binary heap structure to ensure that insertion (`push`) | ||
| * and removal of the maximum element (`pop`) are efficient, both operating in O(log n) time. | ||
| * | ||
| * The heap is constructed using a comparator derived from an optional `getCompareValue` function, | ||
| * which allows comparing complex objects based on a specific property. | ||
| * If no comparison function is provided, direct value comparison is used. | ||
| * | ||
| * Typical use cases include: | ||
| * - Finding the largest element in a dynamic dataset | ||
| * - Implementing efficient top-k queries (e.g., 10 largest items from a large list) | ||
| * - Scheduling or prioritizing tasks based on weight or priority value | ||
| * | ||
| * Elements can be added via `push`, removed with `pop`, and inspected with `peek`. | ||
| * The heap also supports iteration, which destructively yields elements in descending order. | ||
| * | ||
| * Example: | ||
| * ```ts | ||
| * const heap = new MaxHeap<number>(); | ||
| * heap.push(4).push(1).push(3); | ||
| * console.log(heap.pop()); // 4 | ||
| * ``` | ||
| */ | ||
| class MaxHeap<T = number> { | ||
| private heap: Heap<T>; | ||
|
|
||
| constructor(getCompareValue?: GetCompareValue<T>) { | ||
| this.heap = new Heap<T>(getMaxCompare(getCompareValue)); | ||
| } | ||
|
|
||
| push(value: T): this { | ||
| this.heap.push(value); | ||
| return this; | ||
| } | ||
|
|
||
| pop(): T | null { | ||
| return this.heap.pop(); | ||
| } | ||
|
|
||
| peek(): T | null { | ||
| return this.heap.peek(); | ||
| } | ||
|
|
||
| size(): number { | ||
| return this.heap.size(); | ||
| } | ||
|
|
||
| isEmpty(): boolean { | ||
| return this.heap.isEmpty(); | ||
| } | ||
|
|
||
| clear(): void { | ||
| this.heap.clear(); | ||
| } | ||
|
|
||
| *[Symbol.iterator](): Iterator<T> { | ||
| let size = this.size(); | ||
| while (size-- > 0) { | ||
| const poppedValue = this.pop(); | ||
| if (poppedValue === null) { | ||
| break; | ||
| } | ||
| yield poppedValue; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export type {GetCompareValue}; | ||
| export {MaxHeap}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just curious. why did we remove this? I confirmed we don't need it, but I'd like to understand the reason.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This introduces a bug that you cannot see search results when in offline mode. I had to remove this to test with imported onyx state and then left that. Should I revert that change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're fine as is, thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like this caused #67755
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bernhardoj this change was changed because no data was visible when user is not fully reconnected.
Issue #67755 is similar but not exactly the same.