Skip to content

Optimize /Search call in useSearchHighlightAndScroll hook #60896

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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 30, 2025
3 changes: 1 addition & 2 deletions src/components/Search/SearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {PressableWithFeedback} from '@components/Pressable';
import type ChatListItem from '@components/SelectionList/ChatListItem';
import type ReportListItem from '@components/SelectionList/Search/ReportListItem';
import type TransactionListItem from '@components/SelectionList/Search/TransactionListItem';
import type {ExtendedTargetedEvent, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types';
import type {ExtendedTargetedEvent, ReportListItemType, SearchListItem} from '@components/SelectionList/types';
import Text from '@components/Text';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
Expand All @@ -31,7 +31,6 @@ import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

type SearchListItem = TransactionListItemType | ReportListItemType | ReportActionListItemType;
type SearchListItemComponentType = typeof TransactionListItem | typeof ChatListItem | typeof ReportListItem;

type SearchListHandle = {
Expand Down
16 changes: 9 additions & 7 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
import useSearchPusherUpdates from '@hooks/useSearchPusherUpdates';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {search, updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
Expand Down Expand Up @@ -151,9 +152,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS
const {type, status, sortBy, sortOrder, hash, groupBy} = queryJSON;

const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true});
const previousTransactions = usePrevious(transactions);
const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: true});
const previousReportActions = usePrevious(reportActions);
const {translate} = useLocalize();
const shouldGroupByReports = groupBy === CONST.SEARCH.GROUP_BY.REPORTS;

Expand Down Expand Up @@ -200,14 +199,17 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS
search({queryJSON, offset});
}, [isOffline, offset, queryJSON]);

// Use custom hook to detect and handle Pusher updates
useSearchPusherUpdates({
isOffline,
queryJSON,
transactions,
reportActions,
});

const {newSearchResultKey, handleSelectionListScroll} = useSearchHighlightAndScroll({
searchResults,
transactions,
previousTransactions,
queryJSON,
offset,
reportActions,
previousReportActions,
});

// There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded
Expand Down
3 changes: 3 additions & 0 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,8 @@ type SectionListDataType<TItem extends ListItem> = ExtendedSectionListData<TItem

type SortableColumnName = SearchColumnType | typeof CONST.REPORT.TRANSACTION_LIST.COLUMNS.COMMENTS;

type SearchListItem = TransactionListItemType | ReportListItemType | ReportActionListItemType;

export type {
BaseListItemProps,
SelectionListProps,
Expand All @@ -751,5 +753,6 @@ export type {
UserListItemProps,
ReportActionListItemType,
ChatListItemProps,
SearchListItem,
SortableColumnName,
};
102 changes: 25 additions & 77 deletions src/hooks/useSearchHighlightAndScroll.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,28 @@
import isEqual from 'lodash/isEqual';
import {useCallback, useEffect, useRef, useState} from 'react';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import type {SearchQueryJSON} from '@components/Search/types';
import type {ReportActionListItemType, ReportListItemType, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types';
import {search} from '@libs/actions/Search';
import type {ReportListItemType, SearchListItem, SelectionListHandle, TransactionListItemType} from '@components/SelectionList/types';
import {isReportActionEntry} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx';
import type {SearchResults} from '@src/types/onyx';
import usePrevious from './usePrevious';

type UseSearchHighlightAndScroll = {
searchResults: OnyxEntry<SearchResults>;
transactions: OnyxCollection<Transaction>;
previousTransactions: OnyxCollection<Transaction>;
reportActions: OnyxCollection<ReportActions>;
previousReportActions: OnyxCollection<ReportActions>;
queryJSON: SearchQueryJSON;
offset: number;
};

/**
* Hook used to trigger a search when a new transaction or report action is added and handle highlighting and scrolling.
* Hook used to handle highlighting and scrolling for new search results.
*/
function useSearchHighlightAndScroll({searchResults, transactions, previousTransactions, reportActions, previousReportActions, queryJSON, offset}: UseSearchHighlightAndScroll) {
// Ref to track if the search was triggered by this hook
const triggeredByHookRef = useRef(false);
const searchTriggeredRef = useRef(false);
const hasNewItemsRef = useRef(false);
const previousSearchResults = usePrevious(searchResults?.data);
function useSearchHighlightAndScroll({searchResults, queryJSON}: UseSearchHighlightAndScroll) {
const [newSearchResultKey, setNewSearchResultKey] = useState<string | null>(null);
const highlightedIDs = useRef<Set<string>>(new Set());
const initializedRef = useRef(false);
const previousSearchResults = usePrevious(searchResults?.data);
const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT;

// Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types.
useEffect(() => {
const previousTransactionsIDs = Object.keys(previousTransactions ?? {});
const transactionsIDs = Object.keys(transactions ?? {});

const reportActionsIDs = Object.values(reportActions ?? {})
.map((actions) => Object.keys(actions ?? {}))
.flat();
const previousReportActionsIDs = Object.values(previousReportActions ?? {})
.map((actions) => Object.keys(actions ?? {}))
.flat();

if (searchTriggeredRef.current) {
return;
}
const hasTransactionsIDsChange = !isEqual(transactionsIDs, previousTransactionsIDs);
const hasReportActionsIDsChange = !isEqual(reportActionsIDs, previousReportActionsIDs);

// Check if there is a change in the transactions or report actions list
if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange) {
// We only want to highlight new items if the addition of transactions or report actions triggered the search.
// This is because, on deletion of items, the backend sometimes returns old items in place of the deleted ones.
// We don't want to highlight these old items, even if they appear new in the current search results.
hasNewItemsRef.current = isChat ? reportActionsIDs.length > previousReportActionsIDs.length : transactionsIDs.length > previousTransactionsIDs.length;

// Set the flag indicating the search is triggered by the hook
triggeredByHookRef.current = true;

// Trigger the search
search({queryJSON, offset});

// Set the ref to prevent further triggers until reset
searchTriggeredRef.current = true;
}

// Reset the ref when transactions or report actions in chat search type are updated
return () => {
searchTriggeredRef.current = false;
};
}, [transactions, previousTransactions, queryJSON, offset, reportActions, previousReportActions, isChat]);

// Initialize the set with existing IDs only once
useEffect(() => {
if (initializedRef.current || !searchResults?.data) {
Expand All @@ -86,7 +34,7 @@ function useSearchHighlightAndScroll({searchResults, transactions, previousTrans
initializedRef.current = true;
}, [searchResults?.data, isChat]);

// Detect new items (transactions or report actions)
// Detect new items in search results after a change in transactions or report actions
useEffect(() => {
if (!previousSearchResults || !searchResults?.data) {
return;
Expand All @@ -98,7 +46,7 @@ function useSearchHighlightAndScroll({searchResults, transactions, previousTrans
// Find new report action IDs that are not in the previousReportActionIDs and not already highlighted
const newReportActionIDs = currentReportActionIDs.filter((id) => !previousReportActionIDs.includes(id) && !highlightedIDs.current.has(id));

if (!triggeredByHookRef.current || newReportActionIDs.length === 0 || !hasNewItemsRef.current) {
if (newReportActionIDs.length === 0) {
return;
}

Expand All @@ -107,23 +55,25 @@ function useSearchHighlightAndScroll({searchResults, transactions, previousTrans

setNewSearchResultKey(newReportActionKey);
highlightedIDs.current.add(newReportActionID);
} else {
const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults);
const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data);
return;
}

// Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted
const newTransactionIDs = currentTransactionIDs.filter((id) => !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id));
// For expenses/transactions
const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults);
const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data);

if (!triggeredByHookRef.current || newTransactionIDs.length === 0 || !hasNewItemsRef.current) {
return;
}

const newTransactionID = newTransactionIDs.at(0) ?? '';
const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${newTransactionID}`;
// Find new transaction IDs not in previous search results and not already highlighted
const newTransactionIDs = currentTransactionIDs.filter((id) => !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id));

setNewSearchResultKey(newTransactionKey);
highlightedIDs.current.add(newTransactionID);
if (newTransactionIDs.length === 0) {
return;
}

const newTransactionID = newTransactionIDs.at(0) ?? '';
const newTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${newTransactionID}`;

setNewSearchResultKey(newTransactionKey);
highlightedIDs.current.add(newTransactionID);
}, [searchResults?.data, previousSearchResults, isChat]);

// Reset newSearchResultKey after it's been used
Expand All @@ -143,10 +93,10 @@ function useSearchHighlightAndScroll({searchResults, transactions, previousTrans
* Callback to handle scrolling to the new search result.
*/
const handleSelectionListScroll = useCallback(
(data: Array<TransactionListItemType | ReportActionListItemType | ReportListItemType>) => (ref: SelectionListHandle | null) => {
(data: SearchListItem[]) => (ref: SelectionListHandle | null) => {
// Early return if there's no ref, new transaction wasn't brought in by this hook
// or there's no new search result key
if (!ref || !triggeredByHookRef.current || newSearchResultKey === null) {
if (!ref || newSearchResultKey === null) {
return;
}

Expand Down Expand Up @@ -181,8 +131,6 @@ function useSearchHighlightAndScroll({searchResults, transactions, previousTrans

// Perform the scrolling action
ref.scrollToIndex(indexOfNewItem);
// Reset the trigger flag to prevent unintended future scrolls and highlights
triggeredByHookRef.current = false;
},
[newSearchResultKey, isChat],
);
Expand Down
71 changes: 71 additions & 0 deletions src/hooks/useSearchPusherUpdates.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, could you please add unit tests for this hook? Same as we have unit tests for the original hook, would be great to have that covered for this one too

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {useEffect} from 'react';
import type {OnyxCollection} from 'react-native-onyx';
import type {SearchQueryJSON} from '@components/Search/types';
import {search} from '@libs/actions/Search';
import CONST from '@src/CONST';
import type {ReportActions, Transaction} from '@src/types/onyx';
import usePrevious from './usePrevious';

/**
* Hook to detect new transactions or report actions from Pusher and trigger a search refresh
*/
function useSearchPusherUpdates({
isOffline,
queryJSON,
transactions,
reportActions,
}: {
isOffline: boolean;
queryJSON: SearchQueryJSON;
transactions?: OnyxCollection<Transaction>;
reportActions?: OnyxCollection<ReportActions>;
}) {
const previousTransactions = usePrevious(transactions);
const previousReportActions = usePrevious(reportActions);
const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT;

// Detect Pusher updates and trigger search
useEffect(() => {
if (isOffline) {
return;
}

// Only proceed if we have stable references to compare
if (isChat && (!reportActions || !previousReportActions)) {
return;
}

if (!isChat && (!transactions || !previousTransactions)) {
return;
}

// For chat, check if there are new report actions
if (isChat) {
const currentReportActionsIDs = Object.values(reportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {}));

const previousReportActionsIDs = new Set(Object.values(previousReportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {})));

// Only trigger search if Pusher added new report actions
const hasNewReportActions = currentReportActionsIDs.length > previousReportActionsIDs.size && currentReportActionsIDs.some((id) => !previousReportActionsIDs.has(id));

if (hasNewReportActions) {
search({queryJSON, offset: 0});
}

return;
}

// For expenses/transactions, check if there are new transactions
const currentTransactionIDs = Object.keys(transactions ?? {});
const previousTransactionIDs = Object.keys(previousTransactions ?? {});

// Only trigger search if Pusher added new transactions
const hasNewTransactions = currentTransactionIDs.length > previousTransactionIDs.length && currentTransactionIDs.some((id) => !previousTransactionIDs.includes(id));

if (hasNewTransactions) {
search({queryJSON, offset: 0});
}
}, [isOffline, queryJSON, transactions, previousTransactions, reportActions, previousReportActions, isChat]);
}

export default useSearchPusherUpdates;
Loading