Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d741378
Search selection: extract pure selection builders into selectionBuild…
adhorodyski Jun 5, 2026
613befb
Search selection: add provider write-core (toggle/toggleAll/reconcile)
adhorodyski Jun 5, 2026
a489cd2
Search selection: add renderless SearchSelectionController
adhorodyski Jun 5, 2026
f21fac5
Search selection: move write path off <Search> onto the provider/cont…
adhorodyski Jun 5, 2026
159b162
Search selection: drop selectedTransactions from FlashList extraData
adhorodyski Jun 5, 2026
bda6956
Merge remote-tracking branch 'exfy/main' into perf/search-selection-w…
adhorodyski Jun 5, 2026
7e21f53
Search selection: consolidate SearchData type, fix knip/spell CI
adhorodyski Jun 8, 2026
4af4f44
Merge remote-tracking branch 'exfy/main' into perf/search-selection-w…
adhorodyski Jun 9, 2026
dbe9c42
Search selection: ref-free write path + isolate SearchPage selection …
adhorodyski Jun 10, 2026
6cdb211
Merge remote-tracking branch 'exfy/main' into perf/search-selection-w…
adhorodyski Jun 10, 2026
e5e16dc
Merge remote-tracking branch 'upstream/main' into perf/search-selecti…
BartekObudzinski Jun 11, 2026
cff415b
fix: route mobile row-tap toggle through SearchList provider context
BartekObudzinski Jun 11, 2026
30c4f9e
Merge remote-tracking branch 'upstream/main' into perf/search-selecti…
BartekObudzinski Jun 11, 2026
e04d3ac
Merge remote-tracking branch 'upstream/main' into perf/search-selecti…
BartekObudzinski Jun 12, 2026
723a219
fix: replace unsafe type assertions in Search selection write-side wi…
BartekObudzinski Jun 12, 2026
57e27db
Merge remote-tracking branch 'upstream/main' into perf/search-selecti…
BartekObudzinski Jun 16, 2026
b83a7d0
refactor: address review feedback on Search selection builders
BartekObudzinski Jun 16, 2026
c568c5b
refactor: drop redundant displayName on SearchWriteActionsProvider
BartekObudzinski Jun 16, 2026
d1139e1
fix: clear Search selection when results become empty
BartekObudzinski Jun 16, 2026
6f9f6dc
fix: reserve Search footer offline-indicator offset for selection-onl…
BartekObudzinski Jun 16, 2026
e338f6b
Revert "fix: reserve Search footer offline-indicator offset for selec…
BartekObudzinski Jun 16, 2026
f1556f8
fix: reserve Search offline-indicator offset when a selection shows t…
BartekObudzinski Jun 16, 2026
5a0ff5b
Merge remote-tracking branch 'upstream/main' into perf/search-selecti…
BartekObudzinski Jun 16, 2026
dec1e19
docs: document Search selection builders and eslint-disable rationale
BartekObudzinski Jun 17, 2026
6c53522
docs: document each property of the Search selection param types
BartekObudzinski Jun 17, 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
6 changes: 6 additions & 0 deletions src/components/Search/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
SearchQueryContext,
SearchResultsActionsContext,
SearchResultsContext,
SearchRowSelectionActionsContext,
SearchSelectionActionsContext,
SearchSelectionContext,
} from './SearchContextDefinitions';
Expand Down Expand Up @@ -37,6 +38,10 @@ function useSearchSelectionActions() {
return useContext(SearchSelectionActionsContext);
}

function useSearchRowSelectionActions() {
return useContext(SearchRowSelectionActionsContext);
}

export {
SearchQueryContext,
SearchQueryActionsContext,
Expand All @@ -50,4 +55,5 @@ export {
useSearchResultsActions,
useSearchSelectionContext,
useSearchSelectionActions,
useSearchRowSelectionActions,
};
27 changes: 25 additions & 2 deletions src/components/Search/SearchContextDefinitions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import React from 'react';
import type {SearchKey, SearchTypeMenuItem} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import type {SearchQueryActionsValue, SearchQueryContextValue, SearchResultsActionsValue, SearchResultsContextValue, SearchSelectionActionsValue, SearchSelectionContextValue} from './types';
import type {
SearchQueryActionsValue,
SearchQueryContextValue,
SearchResultsActionsValue,
SearchResultsContextValue,
SearchRowSelectionActionsValue,
SearchSelectionActionsValue,
SearchSelectionContextValue,
} from './types';

// This file holds the bare React.createContext() calls so they can be imported by `@hooks/useOnyx`
// without triggering the SearchQueryProvider -> useCardFeedsForDisplay -> @hooks/useOnyx ->
Expand Down Expand Up @@ -46,18 +54,33 @@ const defaultSearchSelectionContext: SearchSelectionContextValue = {

const defaultSearchSelectionActions: SearchSelectionActionsValue = {
setSelectedTransactions: () => {},
applySelection: () => {},
setSelectedReports: () => {},
setCurrentSelectedTransactionReportID: () => {},
clearSelectedTransactions: () => {},
removeTransaction: () => {},
selectAllMatchingItems: () => {},
};

const defaultRowSelectionActions: SearchRowSelectionActionsValue = {
toggle: () => {},
toggleAll: () => {},
};

const SearchQueryContext = React.createContext<SearchQueryContextValue>(defaultSearchQueryContext);
const SearchQueryActionsContext = React.createContext<SearchQueryActionsValue>(defaultSearchQueryActions);
const SearchResultsContext = React.createContext<SearchResultsContextValue>(defaultSearchResultsContext);
const SearchResultsActionsContext = React.createContext<SearchResultsActionsValue>(defaultSearchResultsActions);
const SearchSelectionContext = React.createContext<SearchSelectionContextValue>(defaultSearchSelectionContext);
const SearchSelectionActionsContext = React.createContext<SearchSelectionActionsValue>(defaultSearchSelectionActions);
const SearchRowSelectionActionsContext = React.createContext<SearchRowSelectionActionsValue>(defaultRowSelectionActions);

export {SearchQueryContext, SearchQueryActionsContext, SearchResultsContext, SearchResultsActionsContext, SearchSelectionContext, SearchSelectionActionsContext};
export {
SearchQueryContext,
SearchQueryActionsContext,
SearchResultsContext,
SearchResultsActionsContext,
SearchSelectionContext,
SearchSelectionActionsContext,
SearchRowSelectionActionsContext,
};
6 changes: 3 additions & 3 deletions src/components/Search/SearchList/BaseSearchList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function BaseSearchList({
contentContainerStyle,
flattenedItemsLength,
newTransactions,
selectedTransactions,
isAttendeesEnabledForMovingPolicy,
nonPersonalAndWorkspaceCards,
stickyHeaderIndices,
stickyHeaderConfig,
Expand Down Expand Up @@ -165,8 +165,8 @@ function BaseSearchList({
}, [setHasKeyBeenPressed]);

const extraData = useMemo(
() => [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards],
[focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards],
() => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy],
[focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy],
);

return (
Expand Down
6 changes: 3 additions & 3 deletions src/components/Search/SearchList/BaseSearchList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list';
import type {RefObject} from 'react';
import type {NativeSyntheticEvent} from 'react-native';
import type {SearchListItem} from '@components/Search/SearchList/ListItem/types';
import type {SearchColumnType, SelectedTransactions} from '@components/Search/types';
import type {SearchColumnType} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
import type {CardList, Transaction} from '@src/types/onyx';

Expand Down Expand Up @@ -45,8 +45,8 @@ type BaseSearchListProps = Pick<
/** The function to scroll to an index */
scrollToIndex?: (index: number, animated?: boolean) => void;

/** Selected transactions for triggering re-render via extraData */
selectedTransactions?: SelectedTransactions;
/** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */
isAttendeesEnabledForMovingPolicy?: boolean;

/** Non-personal and workspace cards for triggering re-render via extraData */
nonPersonalAndWorkspaceCards?: CardList;
Expand Down
68 changes: 40 additions & 28 deletions src/components/Search/SearchList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import MenuItem from '@components/MenuItem';
import Modal from '@components/Modal';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import ScrollView from '@components/ScrollView';
import type {SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedTransactions} from '@components/Search/types';
import {useSearchRowSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';
import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
import {useEditingCellState} from '@components/TransactionItemRow/EditableCell';
import useKeyboardState from '@hooks/useKeyboardState';
Expand Down Expand Up @@ -86,12 +87,6 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten
/** Whether this is a multi-select list */
canSelectMultiple: boolean;

/** Callback to fire when a checkbox is pressed */
onCheckboxPress: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void;

/** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
onAllCheckboxPress: () => void;

/** Styles to apply to SelectionList container */
containerStyle?: StyleProp<ViewStyle>;

Expand Down Expand Up @@ -121,9 +116,6 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten

newTransactions?: Transaction[];

/** Selected transactions for determining isSelected state */
selectedTransactions: SelectedTransactions;

/** Non-personal and workspace cards (same drill path as former custom card names for rows) */
nonPersonalAndWorkspaceCards?: CardList;

Expand All @@ -133,6 +125,9 @@ type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'conten
/** Whether the action column should use its wider variant (e.g. when there is at least one deleted transaction) */
isActionColumnWide?: boolean;

/** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */
isAttendeesEnabledForMovingPolicy?: boolean;

/** Reference to the outer element */
ref?: ForwardedRef<SearchListHandle>;
};
Expand Down Expand Up @@ -195,10 +190,8 @@ function SearchList({
ListItem,
SearchTableHeader,
onSelectRow,
onCheckboxPress,
canSelectMultiple,
onScroll = () => {},
onAllCheckboxPress,
contentContainerStyle,
onEndReachedThreshold,
onEndReached,
Expand All @@ -213,13 +206,15 @@ function SearchList({
isMobileSelectionModeEnabled,
newTransactions = [],
nonPersonalAndWorkspaceCards,
selectedTransactions,
hasLoadedAllTransactions,
isActionColumnWide,
isAttendeesEnabledForMovingPolicy,
ref,
}: SearchListProps) {
const styles = useThemeStyles();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']);
const {toggle, toggleAll} = useSearchRowSelectionActions();
const {selectedTransactions} = useSearchSelectionContext();

const {groupBy, type} = queryJSON;
const flattenedItems = useMemo(() => {
Expand Down Expand Up @@ -435,7 +430,7 @@ function SearchList({
return;
}

onCheckboxPress(item, itemTransactions);
toggle(item, itemTransactions);
};

const handleLongPressRow = useCallback(
Expand All @@ -460,10 +455,27 @@ function SearchList({
turnOnMobileSelectionMode();
setIsModalVisible(false);

if (onCheckboxPress && longPressedItem) {
onCheckboxPress?.(longPressedItem, longPressedItemTransactions);
if (longPressedItem) {
toggle(longPressedItem, longPressedItemTransactions);
}
}, [longPressedItem, onCheckboxPress, longPressedItemTransactions]);
}, [longPressedItem, toggle, longPressedItemTransactions]);

// In mobile selection mode a row tap toggles selection. This must live here (not in <Search>) because
// <Search> renders SearchWriteActionsProvider as its child, so the `toggle` it reads is the default no-op;
// SearchList sits inside the provider and gets the real one.
const handleSelectRow = useCallback(
(item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => {
if (isMobileSelectionModeEnabled) {
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
return;
}
toggle(item);
return;
}
onSelectRow(item, transactionPreviewData, event);
},
[isMobileSelectionModeEnabled, toggle, onSelectRow],
);

/**
* Scrolls to the desired item index in the FlashList (listData coordinates).
Expand Down Expand Up @@ -549,8 +561,8 @@ function SearchList({
canSelectMultiple={canSelectMultiple}
isExpanded={expandedGroups.has(originalKey)}
onToggle={() => onToggleGroup(originalKey)}
onSelectRow={onSelectRow}
onCheckboxPress={onCheckboxPress}
onSelectRow={handleSelectRow}
onCheckboxPress={toggle}
onLongPressRow={isMobileSelectionModeEnabled ? handleLongPressRowInMobileSelectionMode : handleLongPressRow}
onFocus={onFocus}
isFocused={isItemFocused}
Expand Down Expand Up @@ -579,8 +591,8 @@ function SearchList({
searchType={type}
columns={columns}
canSelectMultiple={canSelectMultiple}
onSelectRow={onSelectRow}
onCheckboxPress={onCheckboxPress}
onSelectRow={handleSelectRow}
onCheckboxPress={toggle}
onLongPressRow={isMobileSelectionModeEnabled ? handleLongPressRowInMobileSelectionMode : handleLongPressRow}
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
onUndelete={handleUndelete}
Expand Down Expand Up @@ -609,9 +621,9 @@ function SearchList({
<ListItem
showTooltip
isFocused={isItemFocused}
onSelectRow={onSelectRow}
onSelectRow={handleSelectRow}
onLongPressRow={isMobileSelectionModeEnabled ? handleLongPressRowInMobileSelectionMode : handleLongPressRow}
onSelectionButtonPress={onCheckboxPress}
onSelectionButtonPress={toggle}
canSelectMultiple={canSelectMultiple}
item={item}
columns={columns}
Expand Down Expand Up @@ -642,11 +654,11 @@ function SearchList({
styles.overflowHidden,
hasItemsBeingRemoved,
ListItem,
onSelectRow,
handleSelectRow,
handleLongPressRow,
handleLongPressRowInMobileSelectionMode,
isMobileSelectionModeEnabled,
onCheckboxPress,
toggle,
canSelectMultiple,
columns,
lastPaymentMethod,
Expand Down Expand Up @@ -688,7 +700,7 @@ function SearchList({
selectedItemsLength={selectedItemsLength}
totalItems={totalItems}
shouldShowTextButton={selectAllButtonVisible}
onAllCheckboxPress={onAllCheckboxPress}
onAllCheckboxPress={toggleAll}
/>
)}

Expand All @@ -698,7 +710,7 @@ function SearchList({
<BaseSearchList
data={listData}
renderItem={renderItem}
onSelectRow={onSelectRow}
onSelectRow={handleSelectRow}
keyExtractor={keyExtractor}
onScroll={onScroll}
showsVerticalScrollIndicator={false}
Expand All @@ -713,7 +725,7 @@ function SearchList({
onLayout={onLayout}
contentContainerStyle={contentContainerStyle}
newTransactions={newTransactions}
selectedTransactions={selectedTransactions}
isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy}
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
stickyHeaderIndices={stickyHeaderIndices}
getItemType={getItemType}
Expand Down
58 changes: 58 additions & 0 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
import {isGroupEntry} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
import type {SearchResults} from '@src/types/onyx';
import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext';
import SearchPageFooter from './SearchPageFooter';

type SearchSelectionFooterProps = {
/** The (sorting-aware) results the page is displaying; source of the footer's totals metadata. */
searchResults: OnyxEntry<SearchResults>;
};

// Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this
// footer — not SearchPage and the <Search> list it contains.
function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true);

const metadata = searchResults?.search;
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count);

if (!shouldShowFooter) {
return null;
}

const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected);
const selectedTransactionItems = Object.values(selectedTransactions);
const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency;
const count = shouldUseClientTotal
? selectedTransactionsKeys.reduce((acc, key) => {
if (isGroupEntry(key)) {
const group = currentSearchResults?.data?.[key];
return acc + (group?.count ?? 0);
}
const item = selectedTransactions[key];
if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) {
return acc;
}
return acc + 1;
}, 0)
: metadata?.count;
const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total;

return (
<SearchPageFooter
count={count}
total={total}
currency={currency}
/>
);
}

export default SearchSelectionFooter;
Loading
Loading