From 93787e2fd8e217335417b270d7eea9e302ba41b7 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 20 Feb 2026 11:45:03 -0700 Subject: [PATCH 1/6] Add SearchListItemDescriptor type and toSearchListItemDescriptor Co-authored-by: Cursor --- src/components/Search/types.ts | 20 ++++++++++++ src/libs/SearchUIUtils.ts | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index e671df62f8d50..4b190c56351ee 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -322,6 +322,25 @@ type BankAccountMenuItem = { value: PaymentMethod; }; +/** + * Minimal descriptor for a search list item. Used to pass only IDs to the list + * so that full item data can be resolved per-row via useSearchListItem. + */ +type SearchListItemDescriptor = { + /** Stable key for list identity and selection */ + keyForList: string; + /** Search data type (expense, expense_report, chat, task) or group-by variant */ + type: SearchDataTypes | string; + transactionID?: string; + reportID?: string; + reportActionID?: string; + policyID?: string; + /** For group-by items: query hash to load transactions snapshot */ + transactionsQueryJSON?: SearchQueryJSON; + /** For group-by items: which group (e.g. EXPENSE_REPORT, FROM, CARD) */ + groupedBy?: SearchGroupBy; +}; + /** Union type representing all possible grouped transaction item types used in chart views */ type GroupedItem = | TransactionMemberGroupListItemType @@ -336,6 +355,7 @@ type GroupedItem = | TransactionQuarterGroupListItemType; export type { + SearchListItemDescriptor, SelectedTransactionInfo, SelectedTransactions, SearchColumnType, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index c1cac0beed9d2..de22a57b7bdf0 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -21,6 +21,7 @@ import type { SearchDatePreset, SearchFilterKey, SearchGroupBy, + SearchListItemDescriptor, SearchQueryJSON, SearchStatus, SearchView, @@ -1148,6 +1149,60 @@ function isGroupedItemArray(data: ListItem[]): data is GroupedItem[] { return data.length === 0 || (first !== undefined && isTransactionGroupListItemType(first) && 'groupedBy' in first); } +/** + * Converts a full SearchListItem to a minimal descriptor (keyForList + type + IDs). + * Used so the list can pass only descriptors and resolve full items per-row. + */ +function toSearchListItemDescriptor(item: SearchListItem): SearchListItemDescriptor { + if (isTransactionListItemType(item)) { + return { + keyForList: item.keyForList, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + transactionID: item.transactionID, + reportID: item.reportID, + policyID: item.policyID, + }; + } + if (isReportActionListItemType(item)) { + return { + keyForList: item.keyForList, + type: CONST.SEARCH.DATA_TYPES.CHAT, + reportID: item.reportID, + reportActionID: item.reportActionID, + }; + } + if (isTaskListItemType(item)) { + return { + keyForList: item.keyForList, + type: CONST.SEARCH.DATA_TYPES.TASK, + reportID: item.reportID, + }; + } + if (isTransactionReportGroupListItemType(item)) { + return { + keyForList: item.keyForList ?? '', + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + reportID: item.reportID, + policyID: item.policyID, + transactionsQueryJSON: item.transactionsQueryJSON, + groupedBy: item.groupedBy, + }; + } + if (isTransactionGroupListItemType(item)) { + return { + keyForList: item.keyForList ?? '', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + reportID: 'reportID' in item ? item.reportID : undefined, + transactionsQueryJSON: item.transactionsQueryJSON, + groupedBy: 'groupedBy' in item ? item.groupedBy : undefined, + }; + } + return { + keyForList: (item as ListItem).keyForList ?? '', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + }; +} + /** * Type guard that checks if something is a TransactionListItemType */ @@ -4424,5 +4479,6 @@ export { getToFieldValueForTransaction, isTodoSearch, adjustTimeRangeToDateFilters, + toSearchListItemDescriptor, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, ArchivedReportsIDSet}; From 9771f062e2d01645a13a5e033cfa6202f7e882a5 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 20 Feb 2026 11:46:36 -0700 Subject: [PATCH 2/6] Add SearchListItemsCacheContext and useSearchListItem hook Co-authored-by: Cursor --- .../Search/SearchListItemsCacheContext.tsx | 23 ++++++++++++ src/hooks/useSearchListItem.ts | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/components/Search/SearchListItemsCacheContext.tsx create mode 100644 src/hooks/useSearchListItem.ts diff --git a/src/components/Search/SearchListItemsCacheContext.tsx b/src/components/Search/SearchListItemsCacheContext.tsx new file mode 100644 index 0000000000000..bb7b1b50928a6 --- /dev/null +++ b/src/components/Search/SearchListItemsCacheContext.tsx @@ -0,0 +1,23 @@ +import React, {createContext, useContext, useMemo, useRef} from 'react'; +import type {MutableRefObject} from 'react'; +import type {SearchListItem} from '@components/SelectionListWithSections/types'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type SearchListItemsCacheRef = MutableRefObject | null>; + +const SearchListItemsCacheContext = createContext(null); + +function SearchListItemsCacheProvider({children}: ChildrenProps) { + const itemsCacheRef = useRef | null>(null); + const value = useMemo(() => itemsCacheRef, []); + return ( + {children} + ); +} + +function useSearchListItemsCacheRef(): SearchListItemsCacheRef | null { + return useContext(SearchListItemsCacheContext); +} + +export {SearchListItemsCacheProvider, useSearchListItemsCacheRef}; +export type {SearchListItemsCacheRef}; diff --git a/src/hooks/useSearchListItem.ts b/src/hooks/useSearchListItem.ts new file mode 100644 index 0000000000000..f1512efc01f27 --- /dev/null +++ b/src/hooks/useSearchListItem.ts @@ -0,0 +1,37 @@ +import {useMemo} from 'react'; +import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchListItemsCacheRef} from '@components/Search/SearchListItemsCacheContext'; +import type {SearchListItemDescriptor} from '@components/Search/types'; +import type {SearchListItem} from '@components/SelectionListWithSections/types'; + +type UseSearchListItemResult = { + item: SearchListItem | null; + isSelected: boolean; +}; + +/** + * Resolves full list item and selection state for a search list row from a descriptor. + * Reads from the search list items cache (populated by Search when sortedData is computed) + * and selection state from SearchContext. Use in each list row so only that row re-renders + * when its data or selection changes, instead of the whole list. + */ +function useSearchListItem(descriptor: SearchListItemDescriptor): UseSearchListItemResult { + const itemsCacheRef = useSearchListItemsCacheRef(); + const {selectedTransactions} = useSearchContext(); + + return useMemo(() => { + const cache = itemsCacheRef?.current; + const cachedItem = (cache?.get(descriptor.keyForList) ?? null) as SearchListItem | null; + const isSelected = !!(cachedItem && selectedTransactions[descriptor.keyForList]?.isSelected); + + if (!cachedItem) { + return {item: null, isSelected: false}; + } + + const itemWithSelection: SearchListItem = {...cachedItem, isSelected}; + return {item: itemWithSelection, isSelected}; + }, [descriptor.keyForList, itemsCacheRef, selectedTransactions]); +} + +export default useSearchListItem; +export type {UseSearchListItemResult}; From e104250f376d5ec1ecc991493d62afa069d82ca8 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 20 Feb 2026 11:58:24 -0700 Subject: [PATCH 3/6] Search: add orderedDescriptors, cache provider, and pass descriptors to SearchList Co-authored-by: Cursor --- .../BaseSearchList/index.native.tsx | 6 +- .../SearchList/BaseSearchList/index.tsx | 7 +- .../Search/SearchList/BaseSearchList/types.ts | 18 +- src/components/Search/SearchList/index.tsx | 241 ++++++++++++++++-- src/hooks/useSearchListItem.ts | 3 + src/libs/SearchUIUtils.ts | 4 +- 6 files changed, 237 insertions(+), 42 deletions(-) diff --git a/src/components/Search/SearchList/BaseSearchList/index.native.tsx b/src/components/Search/SearchList/BaseSearchList/index.native.tsx index 792fc0dfc0c4a..7c37713b6117a 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.native.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.native.tsx @@ -1,10 +1,10 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback} from 'react'; import Animated from 'react-native-reanimated'; -import type {SearchListItem} from '@components/SelectionListWithSections/types'; import type BaseSearchListProps from './types'; +import type {SearchListDataItem} from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -20,7 +20,7 @@ function BaseSearchList({ contentContainerStyle, }: BaseSearchListProps) { const renderItemWithoutKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { + ({item, index}: {item: SearchListDataItem; index: number}) => { return renderItem(item, index, false, undefined); }, [renderItem], diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index e345b3a7c8ecf..5cee34479e3d5 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -3,15 +3,16 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import Animated from 'react-native-reanimated'; -import type {ExtendedTargetedEvent, SearchListItem} from '@components/SelectionListWithSections/types'; +import type {ExtendedTargetedEvent} from '@components/SelectionListWithSections/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {isMobileChrome} from '@libs/Browser'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import CONST from '@src/CONST'; import type BaseSearchListProps from './types'; +import type {SearchListDataItem} from './types'; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); function BaseSearchList({ data, @@ -58,7 +59,7 @@ function BaseSearchList({ }); const renderItemWithKeyboardFocus = useCallback( - ({item, index}: {item: SearchListItem; index: number}) => { + ({item, index}: {item: SearchListDataItem; index: number}) => { const isItemFocused = focusedIndex === index; const onFocus = (event: NativeSyntheticEvent) => { diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index e0fe33cadc937..c242b55410f16 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -1,12 +1,14 @@ import type {FlashListProps, FlashListRef} from '@shopify/flash-list'; import type {ForwardedRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; -import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; +import type {SearchColumnType, SearchListItemDescriptor, SelectedTransactions} from '@components/Search/types'; import type {ExtendedTargetedEvent, SearchListItem} from '@components/SelectionListWithSections/types'; import type {Transaction} from '@src/types/onyx'; +export type SearchListDataItem = SearchListItem | SearchListItemDescriptor; + type BaseSearchListProps = Pick< - FlashListProps, + FlashListProps, | 'onScroll' | 'contentContainerStyle' | 'onEndReached' @@ -17,11 +19,11 @@ type BaseSearchListProps = Pick< | 'showsVerticalScrollIndicator' | 'onLayout' > & { - /** The data to display in the list */ - data: SearchListItem[]; + /** The data to display in the list (full items or descriptors when using useSearchListItem) */ + data: SearchListDataItem[]; /** The function to render each item in the list */ - renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; + renderItem: (item: SearchListDataItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element; /** The columns that might change to trigger re-render via extraData */ columns: SearchColumnType[]; @@ -32,11 +34,11 @@ type BaseSearchListProps = Pick< /** The length of the flattened items in the list */ flattenedItemsLength: number; - /** The callback, which is run when a row is pressed */ - onSelectRow: (item: SearchListItem) => void; + /** The callback, which is run when a row is pressed (receives item or descriptor when using descriptor flow) */ + onSelectRow: (item: SearchListDataItem) => void; /** The ref to the list */ - ref: ForwardedRef>; + ref: ForwardedRef>; /** The function to scroll to an index */ scrollToIndex?: (index: number, animated?: boolean) => void; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index e89d7c3d3b519..cc28b6961dcd4 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -15,7 +15,10 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; -import type {SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; +import {useSearchListItemsCacheRef} from '@components/Search/SearchListItemsCacheContext'; +import type {SearchColumnType, SearchGroupBy, SearchListItemDescriptor, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import useSearchListItem from '@hooks/useSearchListItem'; import type ChatListItem from '@components/SelectionListWithSections/ChatListItem'; import type TaskListItem from '@components/SelectionListWithSections/Search/TaskListItem'; import type TransactionGroupListItem from '@components/SelectionListWithSections/Search/TransactionGroupListItem'; @@ -53,8 +56,12 @@ import variables from '@styles/variables'; import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ThemeStyles} from '@styles/index'; import BaseSearchList from './BaseSearchList'; +import type BaseSearchListProps from './BaseSearchList/types'; const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0); @@ -68,8 +75,18 @@ type SearchListHandle = { scrollToIndex: (index: number, animated?: boolean) => void; }; +type SearchListDataItem = SearchListItem | SearchListItemDescriptor; + +function isDescriptor(item: SearchListDataItem): item is SearchListItemDescriptor { + return typeof item === 'object' && item !== null && 'type' in item && !('report' in item); +} + type SearchListProps = Pick, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & { - data: SearchListItem[]; + /** List data: either full items (legacy) or descriptors when sortedData is provided */ + data: SearchListDataItem[]; + + /** When provided, data is treated as descriptors and this is used for selection/flattened counts */ + sortedData?: SearchListItem[]; /** Default renderer for every item in the list */ ListItem: SearchListItemComponentType; @@ -142,7 +159,7 @@ type SearchListProps = Pick, 'onScroll' | 'conten ref?: ForwardedRef; }; -const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; +const keyExtractor = (item: SearchListDataItem, index: number) => item.keyForList ?? `${index}`; function isTransactionGroupListItemArray(data: SearchListItem[]): data is TransactionGroupListItemType[] { if (data.length <= 0) { @@ -152,6 +169,118 @@ function isTransactionGroupListItemArray(data: SearchListItem[]): data is Transa return typeof firstElement === 'object' && 'transactions' in firstElement; } +/** Row wrapper that resolves full item from descriptor via useSearchListItem and renders ListItem */ +function SearchListItemRow({ + descriptor, + index, + isItemFocused, + onFocus, + ListItem, + onSelectRow, + handleLongPressRow, + onCheckboxPress, + canSelectMultiple, + shouldPreventDefaultFocusOnSelectRow, + hash, + columns, + policies, + allReports, + groupBy, + type, + onDEWModalOpen, + isDEWBetaEnabled, + userWalletTierName, + isUserValidated, + personalDetails, + userBillingFundID, + isOffline, + violations, + customCardNames, + newTransactions, + shouldAnimate, + hasItemsBeingRemoved, + dataLength, + styles, +}: { + descriptor: SearchListItemDescriptor; + index: number; + isItemFocused: boolean; + onFocus?: (event: NativeSyntheticEvent) => void; + ListItem: SearchListItemComponentType; + onSelectRow: SearchListProps['onSelectRow']; + handleLongPressRow: (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => void; + onCheckboxPress: SearchListProps['onCheckboxPress']; + canSelectMultiple: boolean; + shouldPreventDefaultFocusOnSelectRow?: boolean; + hash: number; + columns: SearchColumnType[]; + policies: OnyxCollection; + allReports: OnyxCollection; + groupBy: SearchGroupBy | undefined; + type: SearchDataTypes; + onDEWModalOpen?: () => void; + isDEWBetaEnabled?: boolean; + userWalletTierName: string | undefined; + isUserValidated: boolean | undefined; + personalDetails: OnyxEntry; + userBillingFundID: number | undefined; + isOffline: boolean; + violations: Record | undefined; + customCardNames: Record | undefined; + newTransactions: Transaction[]; + shouldAnimate?: boolean; + hasItemsBeingRemoved: boolean; + dataLength: number; + styles: ThemeStyles; +}) { + const {item} = useSearchListItem(descriptor); + const isDisabled = item?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const shouldApplyAnimation = shouldAnimate && index < dataLength - 1; + const newTransactionID = newTransactions.find((transaction) => item && isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; + + if (!item) { + return null; + } + + return ( + + + + ); +} + function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: SearchListItem, groupBy: SearchGroupBy | undefined) { if (groupBy === CONST.SEARCH.GROUP_BY.CARD) { return transaction.cardID === (groupItem as TransactionCardGroupListItemType).cardID; @@ -197,6 +326,7 @@ function isTransactionMatchWithGroupItem(transaction: Transaction, groupItem: Se function SearchList({ data, + sortedData, ListItem, SearchTableHeader, onSelectRow, @@ -228,23 +358,35 @@ function SearchList({ }: SearchListProps) { const styles = useThemeStyles(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const itemsCacheRef = useSearchListItemsCacheRef(); const {hash, groupBy, type} = queryJSON; + const fullData = sortedData ?? (data as SearchListItem[]); + + const onSelectRowResolved = useCallback( + (itemOrDescriptor: SearchListDataItem, transactionPreviewData?: TransactionPreviewData) => { + const item = isDescriptor(itemOrDescriptor) ? itemsCacheRef?.current?.get(itemOrDescriptor.keyForList) : itemOrDescriptor; + if (item) { + onSelectRow(item, transactionPreviewData); + } + }, + [itemsCacheRef, onSelectRow], + ); const flattenedItems = useMemo(() => { if (groupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { - if (!isTransactionGroupListItemArray(data)) { - return data; + if (!isTransactionGroupListItemArray(fullData)) { + return fullData; } - return data.flatMap((item) => item.transactions); + return fullData.flatMap((item) => item.transactions); } - return data; - }, [data, groupBy, type]); + return fullData; + }, [fullData, groupBy, type]); const emptyReports = useMemo(() => { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { - return data.filter((item) => item.transactions.length === 0); + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(fullData)) { + return fullData.filter((item) => item.transactions.length === 0); } return []; - }, [data, type]); + }, [fullData, type]); const selectedItemsLength = useMemo(() => { const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { @@ -252,7 +394,7 @@ function SearchList({ return acc + (isTransactionSelected ? 1 : 0); }, 0); - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(fullData)) { const selectedEmptyReports = emptyReports.reduce((acc, item) => { const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); return acc + (isEmptyReportSelected ? 1 : 0); @@ -262,10 +404,10 @@ function SearchList({ } return selectedTransactionsCount; - }, [flattenedItems, type, data, emptyReports, selectedTransactions]); + }, [flattenedItems, type, fullData, emptyReports, selectedTransactions]); const totalItems = useMemo(() => { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(fullData)) { const selectableEmptyReports = emptyReports.filter((item) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const selectableTransactions = flattenedItems.filter((item) => { if ('pendingAction' in item) { @@ -283,10 +425,10 @@ function SearchList({ return true; }); return selectableTransactions.length; - }, [data, type, flattenedItems, emptyReports]); + }, [fullData, type, flattenedItems, emptyReports]); const itemsWithSelection = useMemo(() => { - return data.map((item) => { + return fullData.map((item) => { let isSelected = false; let itemWithSelection: SearchListItem = item; @@ -332,11 +474,11 @@ function SearchList({ return {originalItem: item, itemWithSelection, isSelected}; }); - }, [data, canSelectMultiple, selectedTransactions]); + }, [fullData, canSelectMultiple, selectedTransactions]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const listRef = useRef>(null); + const listRef = useRef>(null); const {isKeyboardShown} = useKeyboardState(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const prevDataLength = usePrevious(data.length); @@ -450,15 +592,55 @@ function SearchList({ useImperativeHandle(ref, () => ({scrollToIndex}), [scrollToIndex]); + const useDescriptorFlow = !!sortedData; + const renderItem = useCallback( - (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { - const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + (item: SearchListDataItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => { + if (useDescriptorFlow && isDescriptor(item)) { + return ( + + ); + } + + const fullItem = item as SearchListItem; + const isDisabled = fullItem.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const shouldApplyAnimation = shouldAnimate && index < data.length - 1; - const newTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, item, groupBy))?.transactionID; + const newTransactionID = newTransactions.find((transaction) => isTransactionMatchWithGroupItem(transaction, fullItem, groupBy))?.transactionID; const itemData = itemsWithSelection.at(index); - const itemWithSelection = itemData?.itemWithSelection ?? item; + const itemWithSelection = itemData?.itemWithSelection ?? fullItem; return ( { + onSelectRow(item as SearchListItem); + } + } keyExtractor={keyExtractor} onScroll={onScroll} showsVerticalScrollIndicator={false} @@ -581,7 +770,7 @@ function SearchList({ onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} ListFooterComponent={ListFooterComponent} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={onViewableItemsChanged as BaseSearchListProps['onViewableItemsChanged']} onLayout={onLayout} contentContainerStyle={contentContainerStyle} newTransactions={newTransactions} diff --git a/src/hooks/useSearchListItem.ts b/src/hooks/useSearchListItem.ts index f1512efc01f27..9a17c13e2a990 100644 --- a/src/hooks/useSearchListItem.ts +++ b/src/hooks/useSearchListItem.ts @@ -20,6 +20,8 @@ function useSearchListItem(descriptor: SearchListItemDescriptor): UseSearchListI const {selectedTransactions} = useSearchContext(); return useMemo(() => { + // Reading ref during render is intentional: cache is synced by parent when sortedData changes; we need it to resolve the item for this row. + // eslint-disable-next-line react-hooks/refs -- cache ref is the source of truth for list items, updated by Search when sortedData changes const cache = itemsCacheRef?.current; const cachedItem = (cache?.get(descriptor.keyForList) ?? null) as SearchListItem | null; const isSelected = !!(cachedItem && selectedTransactions[descriptor.keyForList]?.isSelected); @@ -30,6 +32,7 @@ function useSearchListItem(descriptor: SearchListItemDescriptor): UseSearchListI const itemWithSelection: SearchListItem = {...cachedItem, isSelected}; return {item: itemWithSelection, isSelected}; + // eslint-disable-next-line rulesdir/prefer-narrow-hook-dependencies -- itemsCacheRef intentional: ref.current not in deps (ref updates don't trigger re-renders) }, [descriptor.keyForList, itemsCacheRef, selectedTransactions]); } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index de22a57b7bdf0..277a9d7fc7649 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1185,7 +1185,7 @@ function toSearchListItemDescriptor(item: SearchListItem): SearchListItemDescrip reportID: item.reportID, policyID: item.policyID, transactionsQueryJSON: item.transactionsQueryJSON, - groupedBy: item.groupedBy, + groupedBy: item.groupedBy as SearchListItemDescriptor['groupedBy'], }; } if (isTransactionGroupListItemType(item)) { @@ -1194,7 +1194,7 @@ function toSearchListItemDescriptor(item: SearchListItem): SearchListItemDescrip type: CONST.SEARCH.DATA_TYPES.EXPENSE, reportID: 'reportID' in item ? item.reportID : undefined, transactionsQueryJSON: item.transactionsQueryJSON, - groupedBy: 'groupedBy' in item ? item.groupedBy : undefined, + groupedBy: ('groupedBy' in item ? item.groupedBy : undefined) as SearchListItemDescriptor['groupedBy'], }; } return { From 0ef63bf5437a070684a372380edaa332cb346069 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 20 Feb 2026 11:58:45 -0700 Subject: [PATCH 4/6] Wire Search to cache and descriptors: SearchListItemsCacheProvider, setItemsCache, orderedDescriptors, SearchListCacheSync Co-authored-by: Cursor --- .../Search/SearchListItemsCacheContext.tsx | 27 +++- src/components/Search/index.tsx | 143 ++++++++++-------- 2 files changed, 100 insertions(+), 70 deletions(-) diff --git a/src/components/Search/SearchListItemsCacheContext.tsx b/src/components/Search/SearchListItemsCacheContext.tsx index bb7b1b50928a6..0741b71cd1fe3 100644 --- a/src/components/Search/SearchListItemsCacheContext.tsx +++ b/src/components/Search/SearchListItemsCacheContext.tsx @@ -1,23 +1,34 @@ -import React, {createContext, useContext, useMemo, useRef} from 'react'; +import React, {createContext, useCallback, useContext, useMemo, useRef} from 'react'; import type {MutableRefObject} from 'react'; import type {SearchListItem} from '@components/SelectionListWithSections/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; type SearchListItemsCacheRef = MutableRefObject | null>; -const SearchListItemsCacheContext = createContext(null); +type SearchListItemsCacheContextValue = { + itemsCacheRef: SearchListItemsCacheRef; + setItemsCache: (items: SearchListItem[]) => void; +}; + +const SearchListItemsCacheContext = createContext(null); function SearchListItemsCacheProvider({children}: ChildrenProps) { const itemsCacheRef = useRef | null>(null); - const value = useMemo(() => itemsCacheRef, []); - return ( - {children} - ); + const setItemsCache = useCallback((items: SearchListItem[]) => { + itemsCacheRef.current = new Map(items.map((item) => [item.keyForList ?? '', item])); + }, []); + const value = useMemo(() => ({itemsCacheRef, setItemsCache}), [setItemsCache]); + return {children}; } function useSearchListItemsCacheRef(): SearchListItemsCacheRef | null { + const context = useContext(SearchListItemsCacheContext); + return context?.itemsCacheRef ?? null; +} + +function useSearchListItemsCache(): SearchListItemsCacheContextValue | null { return useContext(SearchListItemsCacheContext); } -export {SearchListItemsCacheProvider, useSearchListItemsCacheRef}; -export type {SearchListItemsCacheRef}; +export {SearchListItemsCacheProvider, useSearchListItemsCacheRef, useSearchListItemsCache}; +export type {SearchListItemsCacheRef, SearchListItemsCacheContextValue}; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 876c8b137cc6a..3fbdd93f85741 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,6 +1,7 @@ import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native'; import * as Sentry from '@sentry/react-native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import type {ReactNode} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -64,6 +65,7 @@ import { isTransactionReportGroupListItemType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, + toSearchListItemDescriptor, } from '@libs/SearchUIUtils'; import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import markNavigateAfterExpenseCreateEnd from '@libs/telemetry/markNavigateAfterExpenseCreateEnd'; @@ -84,6 +86,7 @@ import arraysEqual from '@src/utils/arraysEqual'; import SearchChartView from './SearchChartView'; import {useSearchContext} from './SearchContext'; import SearchList from './SearchList'; +import {SearchListItemsCacheProvider, useSearchListItemsCache} from './SearchListItemsCacheContext'; import {SearchScopeProvider} from './SearchScopeProvider'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; @@ -99,6 +102,15 @@ type SearchProps = { onDEWModalOpen?: () => void; }; +/** Syncs sortedData into the list items cache so useSearchListItem can resolve items by descriptor. */ +function SearchListCacheSync({sortedData, children}: {sortedData: SearchListItem[]; children: ReactNode}) { + const cache = useSearchListItemsCache(); + useLayoutEffect(() => { + cache?.setItemsCache(sortedData); + }, [sortedData, cache]); + return children; +} + function mapTransactionItemToSelectedEntry( item: TransactionListItemType, itemTransaction: OnyxEntry, @@ -1062,6 +1074,8 @@ function Search({ [type, status, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, isChat, newSearchResultKeys, hash], ); + const orderedDescriptors = useMemo(() => sortedData.map((item) => toSearchListItemDescriptor(item)), [sortedData]); + useEffect(() => { const currentRoute = Navigation.getActiveRouteWithoutParams(); if (hasErrors && (currentRoute === '/' || (shouldResetSearchQuery && currentRoute === '/search'))) { @@ -1275,67 +1289,72 @@ function Search({ return ( - - - - - ) - } - contentContainerStyle={[styles.pb3, contentContainerStyle]} - containerStyle={[styles.pv0, !tableHeaderVisible && !isSmallScreenWidth && styles.pt3]} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - onScroll={onSearchListScroll} - onEndReachedThreshold={0.75} - onEndReached={fetchMoreResults} - ListFooterComponent={ - shouldShowLoadingMoreItems ? ( - - ) : undefined - } - queryJSON={queryJSON} - columns={columnsToShow} - violations={violations} - onLayout={onLayout} - isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE} - newTransactions={newTransactions} - hasLoadedAllTransactions={hasLoadedAllTransactions} - customCardNames={customCardNames} - /> - + + + + + + + ) + } + contentContainerStyle={[styles.pb3, contentContainerStyle]} + containerStyle={[styles.pv0, !tableHeaderVisible && !isSmallScreenWidth && styles.pt3]} + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + onScroll={onSearchListScroll} + onEndReachedThreshold={0.75} + onEndReached={fetchMoreResults} + ListFooterComponent={ + shouldShowLoadingMoreItems ? ( + + ) : undefined + } + queryJSON={queryJSON} + columns={columnsToShow} + violations={violations} + onLayout={onLayout} + isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} + shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE} + newTransactions={newTransactions} + hasLoadedAllTransactions={hasLoadedAllTransactions} + customCardNames={customCardNames} + /> + + + ); } From bfc2671c7fe446982835baf59458c5754b620ab6 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Fri, 20 Feb 2026 13:35:55 -0700 Subject: [PATCH 5/6] rm props --- .../ChatListItem.tsx | 22 +-- .../Search/TaskListItem.tsx | 5 +- .../SelectionListWithSections/types.ts | 28 ---- src/pages/inbox/report/ReportActionItem.tsx | 140 ++++++++++++++---- 4 files changed, 128 insertions(+), 67 deletions(-) diff --git a/src/components/SelectionListWithSections/ChatListItem.tsx b/src/components/SelectionListWithSections/ChatListItem.tsx index bef3516aed611..aaaee81164e10 100644 --- a/src/components/SelectionListWithSections/ChatListItem.tsx +++ b/src/components/SelectionListWithSections/ChatListItem.tsx @@ -1,7 +1,12 @@ import React from 'react'; +import {isUserValidatedSelector} from '@selectors/Account'; +import {tierNameSelector} from '@selectors/UserWallet'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import FS from '@libs/Fullstory'; import ReportActionItem from '@pages/inbox/report/ReportActionItem'; import variables from '@styles/variables'; @@ -20,15 +25,14 @@ function ChatListItem({ onFocus, onLongPressRow, shouldSyncFocus, - policies, - allReports, - userWalletTierName, - isUserValidated, - personalDetails, - userBillingFundID, }: ChatListItemProps) { const reportActionItem = item as unknown as ReportActionListItemType; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportActionItem?.reportID}`]; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportActionItem?.reportID)}`, {canBeMissing: true}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`, {canBeMissing: true}); + const personalDetails = usePersonalDetails(); + const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, {selector: tierNameSelector, canBeMissing: false}); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector, canBeMissing: true}); + const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); const styles = useThemeStyles(); const theme = useTheme(); const animatedHighlightStyle = useAnimatedHighlightStyle({ @@ -73,9 +77,8 @@ function ChatListItem({ forwardedFSClass={fsClass} > onSelectRow(item)} parentReportAction={undefined} displayAsGroup={false} @@ -85,7 +88,6 @@ function ChatListItem({ isFirstVisibleReportAction={false} shouldDisplayContextMenu={false} shouldShowDraftMessage={false} - policies={policies} shouldShowBorder userWalletTierName={userWalletTierName} isUserValidated={isUserValidated} diff --git a/src/components/SelectionListWithSections/Search/TaskListItem.tsx b/src/components/SelectionListWithSections/Search/TaskListItem.tsx index c91a193a91201..dedae619a2329 100644 --- a/src/components/SelectionListWithSections/Search/TaskListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TaskListItem.tsx @@ -2,9 +2,11 @@ import React from 'react'; import BaseListItem from '@components/SelectionListWithSections/BaseListItem'; import type {ListItem, TaskListItemProps, TaskListItemType} from '@components/SelectionListWithSections/types'; import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import FS from '@libs/Fullstory'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,10 +22,9 @@ function TaskListItem({ onFocus, onLongPressRow, shouldSyncFocus, - allReports, }: TaskListItemProps) { const taskItem = item as unknown as TaskListItemType; - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${taskItem?.parentReportID}`]; + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(taskItem?.parentReportID)}`, {canBeMissing: true}); const styles = useThemeStyles(); const theme = useTheme(); diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index 03ecfb999f286..e67f98cc94faf 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -674,12 +674,6 @@ type TransactionListItemProps = ListItemProps & { type TaskListItemProps = ListItemProps & { /** Whether the item's action is loading */ isLoading?: boolean; - - /** All the data of the report collection */ - allReports?: OnyxCollection; - - /** Personal details list */ - personalDetails: OnyxEntry; }; type ExpenseReportListItemProps = ListItemProps & { @@ -699,7 +693,6 @@ type ExpenseReportListItemProps = ListItemProps & type TransactionGroupListItemProps = ListItemProps & { groupBy?: SearchGroupBy; searchType?: SearchDataTypes; - policies?: OnyxCollection; accountID?: number; columns?: SearchColumnType[]; newTransactionID?: string; @@ -729,27 +722,6 @@ type TransactionGroupListExpandedProps = Pick< type ChatListItemProps = ListItemProps & { queryJSONHash?: number; - - /** The policies which the user has access to */ - policies?: OnyxCollection; - - /** All the data of the report collection */ - allReports?: OnyxCollection; - - /** The report data */ - report?: Report; - - /** The user wallet tierName */ - userWalletTierName: string | undefined; - - /** Whether the user is validated */ - isUserValidated: boolean | undefined; - - /** Personal details list */ - personalDetails: OnyxEntry; - - /** User billing fund ID */ - userBillingFundID: number | undefined; }; type ValidListItem = diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index baa357264828d..17a7928edc752 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -8,6 +8,7 @@ import useOriginalReportID from '@hooks/useOriginalReportID'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getForReportActionTemp, getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDFromReportActionPreview, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { @@ -40,11 +41,11 @@ type ReportActionItemProps = Omit< PureReportActionItemProps, 'taskReport' | 'linkedReport' | 'iouReportOfLinkedReport' | 'currentUserAccountID' | 'personalPolicyID' | 'allTransactionDrafts' > & { - /** All the data of the report collection */ - allReports: OnyxCollection; + /** All the data of the report collection (optional when used from Search list; component subscribes at row level) */ + allReports?: OnyxCollection; - /** All the data of the policy collection */ - policies: OnyxCollection; + /** All the data of the policy collection (optional when used from Search list; component subscribes at row level) */ + policies?: OnyxCollection; /** Whether to show the draft message or not */ shouldShowDraftMessage?: boolean; @@ -72,8 +73,8 @@ type ReportActionItemProps = Omit< }; function ReportActionItem({ - allReports, - policies, + allReports: allReportsProp, + policies: policiesProp, action, report, draftMessage, @@ -90,7 +91,102 @@ function ReportActionItem({ const reportID = report?.reportID; const originalMessage = getOriginalMessage(action); const originalReportID = useOriginalReportID(reportID, action); + const iouReportID = getIOUReportIDFromReportActionPreview(action); + const movedFromReportID = getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM); + const movedToReportID = getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO); + const parentReportID = report?.parentReportID || undefined; + const taskReportID = originalMessage && 'taskReportID' in originalMessage ? originalMessage.taskReportID : undefined; + const linkedReportID = originalMessage && 'linkedReportID' in originalMessage ? originalMessage.linkedReportID : undefined; + + const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(originalReportID)}`, {canBeMissing: true}); + const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(iouReportID)}`, {canBeMissing: true}); + const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(movedFromReportID)}`, {canBeMissing: true}); + const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(movedToReportID)}`, {canBeMissing: true}); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(parentReportID)}`, {canBeMissing: true}); + const [taskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(taskReportID)}`, {canBeMissing: true}); + const [linkedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(linkedReportID)}`, {canBeMissing: true}); + const [iouReportOfLinkedReport] = useOnyx( + `${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(linkedReport && 'iouReportID' in linkedReport ? linkedReport.iouReportID : undefined)}`, + {canBeMissing: true}, + ); + const [policyFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(report?.policyID)}`, {canBeMissing: true}); + + const allReports = useMemo((): OnyxCollection => { + if (allReportsProp) { + return allReportsProp; + } + const key = (id: string | undefined) => (id ? `${ONYXKEYS.COLLECTION.REPORT}${id}` : ''); + const map: OnyxCollection = {}; + if (originalReportID && originalReport) { + map[key(originalReportID)] = originalReport; + } + if (iouReportID && iouReport) { + map[key(iouReportID)] = iouReport; + } + if (movedFromReportID && movedFromReport) { + map[key(movedFromReportID)] = movedFromReport; + } + if (movedToReportID && movedToReport) { + map[key(movedToReportID)] = movedToReport; + } + if (parentReportID && parentReport) { + map[key(parentReportID)] = parentReport; + } + if (taskReportID && taskReport) { + map[key(taskReportID)] = taskReport; + } + if (linkedReportID && linkedReport) { + map[key(linkedReportID)] = linkedReport; + } + if (linkedReport && 'iouReportID' in linkedReport && iouReportOfLinkedReport) { + map[key(linkedReport.iouReportID)] = iouReportOfLinkedReport; + } + if (reportID && report) { + map[key(reportID)] = report; + } + return map; + }, [ + allReportsProp, + originalReportID, + originalReport, + iouReportID, + iouReport, + movedFromReportID, + movedFromReport, + movedToReportID, + movedToReport, + parentReportID, + parentReport, + taskReportID, + taskReport, + linkedReportID, + linkedReport, + iouReportOfLinkedReport, + reportID, + report, + ]); + + const policies = useMemo((): OnyxCollection => { + if (policiesProp) { + return policiesProp; + } + if (!report?.policyID || !policyFromOnyx) { + return {}; + } + return {[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]: policyFromOnyx}; + }, [policiesProp, report?.policyID, policyFromOnyx]); + const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; + const iouReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + const movedFromReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${movedFromReportID}`]; + const movedToReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${movedToReportID}`]; + const parentReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + const taskReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`]; + const linkedReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReportID}`]; + const iouReportOfLinkedReportResolved = + linkedReportResolved && 'iouReportID' in linkedReportResolved + ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReportResolved.iouReportID}`] + : undefined; const isOriginalReportArchived = useReportIsArchived(originalReportID); const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -103,14 +199,11 @@ function ReportActionItem({ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true}); - const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`]; - const movedFromReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM)}`]; - const movedToReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO)}`]; const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const [cardList] = useOnyx(ONYXKEYS.CARD_LIST, {canBeMissing: true}); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); - const transactionsOnIOUReport = useReportTransactions(iouReport?.reportID); + const transactionsOnIOUReport = useReportTransactions(iouReportResolved?.reportID); const transactionID = isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID; const getLinkedTransactionRouteError = useCallback( @@ -122,17 +215,10 @@ function ReportActionItem({ const [linkedTransactionRouteError] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: true, selector: getLinkedTransactionRouteError}); - // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || undefined}`]; const blockedFromConcierge = useBlockedFromConcierge(); - const targetReport = isChatThread(report) ? parentReport : report; + const targetReport = isChatThread(report) ? parentReportResolved : report; const missingPaymentMethod = getIndicatedMissingPaymentMethod(userWalletTierName, targetReport?.reportID, action, bankAccountList); - const taskReport = originalMessage && 'taskReportID' in originalMessage ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalMessage.taskReportID}`] : undefined; - const linkedReport = originalMessage && 'linkedReportID' in originalMessage ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalMessage.linkedReportID}`] : undefined; - const iouReportOfLinkedReport = linkedReport && 'iouReportID' in linkedReport ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReport.iouReportID}`] : undefined; - return ( Date: Fri, 20 Feb 2026 13:50:09 -0700 Subject: [PATCH 6/6] rm subscription from parents --- src/components/Search/SearchList/index.tsx | 68 ++----------------- .../BaseSelectionListItemRenderer.tsx | 14 ---- src/pages/inbox/report/ReportActionItem.tsx | 16 ++--- 3 files changed, 15 insertions(+), 83 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index cc28b6961dcd4..0c3f8797d0ec2 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -1,6 +1,4 @@ import {useFocusEffect, useRoute} from '@react-navigation/native'; -import {isUserValidatedSelector} from '@selectors/Account'; -import {tierNameSelector} from '@selectors/UserWallet'; import type {FlashListProps, FlashListRef, ViewToken} from '@shopify/flash-list'; import React, {useCallback, useContext, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {ForwardedRef} from 'react'; @@ -11,7 +9,6 @@ import Animated, {Easing, FadeOutUp, LinearTransition} from 'react-native-reanim import Checkbox from '@components/Checkbox'; import MenuItem from '@components/MenuItem'; import Modal from '@components/Modal'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; @@ -42,7 +39,6 @@ import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; @@ -55,10 +51,7 @@ import {getTableMinWidth, isTransactionReportGroupListItemType} from '@libs/Sear import variables from '@styles/variables'; import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, Transaction, TransactionViolations} from '@src/types/onyx'; -import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {Transaction, TransactionViolations} from '@src/types/onyx'; import type {ThemeStyles} from '@styles/index'; import BaseSearchList from './BaseSearchList'; import type BaseSearchListProps from './BaseSearchList/types'; @@ -183,16 +176,10 @@ function SearchListItemRow({ shouldPreventDefaultFocusOnSelectRow, hash, columns, - policies, - allReports, groupBy, type, onDEWModalOpen, isDEWBetaEnabled, - userWalletTierName, - isUserValidated, - personalDetails, - userBillingFundID, isOffline, violations, customCardNames, @@ -214,16 +201,10 @@ function SearchListItemRow({ shouldPreventDefaultFocusOnSelectRow?: boolean; hash: number; columns: SearchColumnType[]; - policies: OnyxCollection; - allReports: OnyxCollection; groupBy: SearchGroupBy | undefined; type: SearchDataTypes; onDEWModalOpen?: () => void; isDEWBetaEnabled?: boolean; - userWalletTierName: string | undefined; - isUserValidated: boolean | undefined; - personalDetails: OnyxEntry; - userBillingFundID: number | undefined; isOffline: boolean; violations: Record | undefined; customCardNames: Record | undefined; @@ -260,17 +241,11 @@ function SearchListItemRow({ shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} queryJSONHash={hash} columns={columns} - policies={policies} isDisabled={isDisabled} - allReports={allReports} groupBy={groupBy} searchType={type} onDEWModalOpen={onDEWModalOpen} isDEWBetaEnabled={isDEWBetaEnabled} - userWalletTierName={userWalletTierName} - isUserValidated={isUserValidated} - personalDetails={personalDetails} - userBillingFundID={userBillingFundID} isOffline={isOffline} violations={violations} customCardNames={customCardNames} @@ -490,18 +465,7 @@ function SearchList({ const [isModalVisible, setIsModalVisible] = useState(false); const [longPressedItem, setLongPressedItem] = useState(); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - canBeMissing: true, - }); - - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); - const hasItemsBeingRemoved = prevDataLength && prevDataLength > data.length; - const personalDetails = usePersonalDetails(); - - const [userWalletTierName] = useOnyx(ONYXKEYS.USER_WALLET, {selector: tierNameSelector, canBeMissing: false}); - const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector, canBeMissing: true}); - const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID, {canBeMissing: true}); const route = useRoute(); const {getScrollOffset} = useContext(ScrollOffsetContext); @@ -611,16 +575,10 @@ function SearchList({ shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} hash={hash} columns={columns} - policies={policies} - allReports={allReports} groupBy={groupBy} type={type} onDEWModalOpen={onDEWModalOpen} isDEWBetaEnabled={isDEWBetaEnabled} - userWalletTierName={userWalletTierName ?? ''} - isUserValidated={isUserValidated ?? false} - personalDetails={personalDetails} - userBillingFundID={userBillingFundID} isOffline={isOffline} violations={violations} customCardNames={customCardNames ?? {}} @@ -660,17 +618,11 @@ function SearchList({ shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} queryJSONHash={hash} columns={columns} - policies={policies} isDisabled={isDisabled} - allReports={allReports} groupBy={groupBy} searchType={type} onDEWModalOpen={onDEWModalOpen} isDEWBetaEnabled={isDEWBetaEnabled} - userWalletTierName={userWalletTierName} - isUserValidated={isUserValidated} - personalDetails={personalDetails} - userBillingFundID={userBillingFundID} isOffline={isOffline} violations={violations} customCardNames={customCardNames} @@ -698,12 +650,6 @@ function SearchList({ shouldPreventDefaultFocusOnSelectRow, hash, columns, - policies, - allReports, - userWalletTierName, - isUserValidated, - personalDetails, - userBillingFundID, isOffline, violations, onDEWModalOpen, @@ -754,12 +700,12 @@ function SearchList({ data={data} renderItem={renderItem} onSelectRow={ - sortedData - ? onSelectRowResolved - : (item: SearchListDataItem) => { - onSelectRow(item as SearchListItem); - } - } + sortedData + ? onSelectRowResolved + : (item: SearchListDataItem) => { + onSelectRow(item as SearchListItem); + } + } keyExtractor={keyExtractor} onScroll={onScroll} showsVerticalScrollIndicator={false} diff --git a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx index 8e374b0a08c8e..23a5c4cddfd8b 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListItemRenderer.tsx @@ -1,11 +1,9 @@ import React from 'react'; import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import type useSingleExecution from '@hooks/useSingleExecution'; import {isMobileChrome} from '@libs/Browser'; import {isTransactionGroupListItemType} from '@libs/SearchUIUtils'; -import type {PersonalDetailsList} from '@src/types/onyx'; import type {BaseListItemProps, ExtendedTargetedEvent, ListItem, SelectionListProps} from './types'; type BaseSelectionListItemRendererProps = Omit, 'onSelectRow'> & @@ -18,10 +16,6 @@ type BaseSelectionListItemRendererProps = Omit['singleExecution']; titleStyles?: StyleProp; titleContainerStyles?: StyleProp; - userWalletTierName?: string | undefined; - isUserValidated?: boolean | undefined; - personalDetails?: OnyxEntry; - userBillingFundID?: number | undefined; }; function BaseSelectionListItemRenderer({ @@ -53,10 +47,6 @@ function BaseSelectionListItemRenderer({ titleContainerStyles, shouldUseDefaultRightHandSideCheckmark, canShowProductTrainingTooltip = true, - userWalletTierName, - isUserValidated, - personalDetails, - userBillingFundID, shouldShowRightCaret, shouldHighlightSelectedItem = true, shouldDisableHoverStyle, @@ -112,10 +102,6 @@ function BaseSelectionListItemRenderer({ titleContainerStyles={titleContainerStyles} shouldUseDefaultRightHandSideCheckmark={shouldUseDefaultRightHandSideCheckmark} canShowProductTrainingTooltip={canShowProductTrainingTooltip} - userWalletTierName={userWalletTierName} - isUserValidated={isUserValidated} - personalDetails={personalDetails} - userBillingFundID={userBillingFundID} index={index} shouldShowRightCaret={shouldShowRightCaret} shouldHighlightSelectedItem={shouldHighlightSelectedItem} diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 17a7928edc752..06c6a2875d049 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -39,7 +39,7 @@ import PureReportActionItem from './PureReportActionItem'; type ReportActionItemProps = Omit< PureReportActionItemProps, - 'taskReport' | 'linkedReport' | 'iouReportOfLinkedReport' | 'currentUserAccountID' | 'personalPolicyID' | 'allTransactionDrafts' + 'taskReport' | 'linkedReport' | 'iouReportOfLinkedReport' | 'currentUserAccountID' | 'personalPolicyID' | 'allTransactionDrafts' | 'allReports' | 'policies' > & { /** All the data of the report collection (optional when used from Search list; component subscribes at row level) */ allReports?: OnyxCollection; @@ -94,7 +94,7 @@ function ReportActionItem({ const iouReportID = getIOUReportIDFromReportActionPreview(action); const movedFromReportID = getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM); const movedToReportID = getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO); - const parentReportID = report?.parentReportID || undefined; + const parentReportID = report?.parentReportID ?? undefined; const taskReportID = originalMessage && 'taskReportID' in originalMessage ? originalMessage.taskReportID : undefined; const linkedReportID = originalMessage && 'linkedReportID' in originalMessage ? originalMessage.linkedReportID : undefined; @@ -166,6 +166,8 @@ function ReportActionItem({ report, ]); + // Narrow deps (report?.policyID) intentional; React Compiler infers broader `report` + // eslint-disable-next-line react-hooks/preserve-manual-memoization const policies = useMemo((): OnyxCollection => { if (policiesProp) { return policiesProp; @@ -176,7 +178,7 @@ function ReportActionItem({ return {[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]: policyFromOnyx}; }, [policiesProp, report?.policyID, policyFromOnyx]); - const originalReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; + const originalReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`]; const iouReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; const movedFromReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${movedFromReportID}`]; const movedToReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${movedToReportID}`]; @@ -184,9 +186,7 @@ function ReportActionItem({ const taskReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`]; const linkedReportResolved = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReportID}`]; const iouReportOfLinkedReportResolved = - linkedReportResolved && 'iouReportID' in linkedReportResolved - ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReportResolved.iouReportID}`] - : undefined; + linkedReportResolved && 'iouReportID' in linkedReportResolved ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedReportResolved.iouReportID}`] : undefined; const isOriginalReportArchived = useReportIsArchived(originalReportID); const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -245,9 +245,9 @@ function ReportActionItem({ personalDetails={personalDetails} blockedFromConcierge={blockedFromConcierge} originalReportID={originalReportID} - originalReport={originalReport} + originalReport={originalReportResolved} deleteReportActionDraft={deleteReportActionDraft} - isArchivedRoom={isArchivedNonExpenseReport(originalReport, isOriginalReportArchived)} + isArchivedRoom={isArchivedNonExpenseReport(originalReportResolved, isOriginalReportArchived)} isChronosReport={chatIncludesChronosWithID(originalReportID)} toggleEmojiReaction={toggleEmojiReaction} createDraftTransactionAndNavigateToParticipantSelector={createDraftTransactionAndNavigateToParticipantSelector}