Skip to content
Merged
Show file tree
Hide file tree
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 Jul 10, 2025
3d618f1
set maxResults as undefined for back compability
sosek108 Jul 11, 2025
4700f23
Merge branch 'main' into perf/search-optimization
sosek108 Jul 17, 2025
8db4875
include user to invite
sosek108 Jul 17, 2025
b011a3f
Cleanup code, final improvements, heap usage in autocomplete keys (in,
sosek108 Jul 17, 2025
72eb17f
Additional comments
sosek108 Jul 17, 2025
27543ac
Merge branch 'main' into perf/search-optimization
sosek108 Jul 17, 2025
90e1e6f
Merge branch 'main' into perf/search-optimization
sosek108 Jul 21, 2025
64455d9
Merge branch 'main' into perf/search-optimization
sosek108 Jul 21, 2025
04c13c0
fixes according to comments
sosek108 Jul 21, 2025
97a7b53
add func doc comments
sosek108 Jul 21, 2025
d21e783
Adjust logic to handle proper ordering of personal details
sosek108 Jul 21, 2025
c1dc777
Prettier fix
sosek108 Jul 21, 2025
ae5ba79
Update report filtering to match maxResults
sosek108 Jul 21, 2025
f640e2c
eslint fix
sosek108 Jul 21, 2025
648529d
Merge branch 'main' into perf/search-optimization
sosek108 Jul 22, 2025
471e5b7
Update src/libs/OptionsListUtils.ts
sosek108 Jul 23, 2025
ea88dd1
Add MaxHeap implementation and update heap selection logic
sosek108 Jul 23, 2025
3952e8f
Prettier fix
sosek108 Jul 23, 2025
f7ca5df
Update searchText for personalDetails
sosek108 Jul 24, 2025
5bd0bfc
Update sorting comparator for personalDetails
sosek108 Jul 24, 2025
0b58349
Searching for nonlatin characters
sosek108 Jul 25, 2025
7985588
Merge branch 'main' into perf/search-optimization
kacper-mikolajczak Jul 28, 2025
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
5 changes: 2 additions & 3 deletions src/components/OptionListContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,6 @@ const useOptionsListContext = () => useContext(OptionsListContext);
const useOptionsList = (options?: {shouldInitialize: boolean}) => {
const {shouldInitialize = true} = options ?? {};
const {initializeOptions, options: optionsList, areOptionsInitialized, resetOptions} = useOptionsListContext();
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: false});
const [internalOptions, setInternalOptions] = useState<OptionList>(optionsList);
const prevOptions = useRef<OptionList>(null);

Expand All @@ -286,12 +285,12 @@ const useOptionsList = (options?: {shouldInitialize: boolean}) => {
}, [optionsList]);

useEffect(() => {
if (!shouldInitialize || areOptionsInitialized || isLoadingApp) {

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.

Just curious. why did we remove this? I confirmed we don't need it, but I'd like to understand the reason.

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.

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?

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.

I think we're fine as is, thanks!

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.

Looks like this caused #67755

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.

@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.

if (!shouldInitialize || areOptionsInitialized) {
return;
}

initializeOptions();
}, [shouldInitialize, initializeOptions, areOptionsInitialized, isLoadingApp]);
}, [shouldInitialize, initializeOptions, areOptionsInitialized]);

return useMemo(
() => ({
Expand Down
89 changes: 21 additions & 68 deletions src/components/Search/SearchAutocompleteList.tsx
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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -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(

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.

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,
Expand Down Expand Up @@ -489,8 +452,8 @@ function SearchAutocompleteList(
currencyAutocompleteList,
recentCurrencyAutocompleteList,
taxAutocompleteList,
getParticipantsAutocompleteList,
searchOptions.recentReports,
options,
betas,
typeAutocompleteList,
groupByAutocompleteList,
statusAutocompleteList,
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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,
});
Expand All @@ -593,7 +546,7 @@ function SearchAutocompleteList(
});
throw error;
}
}, [autocompleteQueryValue, filterOptions, searchOptions, isFastSearchInitialized]);
}, [autocompleteQueryValue, searchOptions]);

const debounceHandleSearch = useDebounce(
useCallback(() => {
Expand Down
86 changes: 86 additions & 0 deletions src/libs/MaxHeap.ts
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};
Loading