From 455162149eefba583d09e30435dfc466c51f7c3a Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 21 May 2026 17:01:53 +0000 Subject: [PATCH 01/31] perf: migrate transaction list from .map() to FlashList for virtualization --- .../MoneyRequestReportTransactionList.tsx | 169 ++++++++++-------- 1 file changed, 99 insertions(+), 70 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 42b6cc141d14..02c2f7ac51db 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,5 +1,6 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import {FlashList} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -123,6 +124,8 @@ type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { shouldBeHighlighted?: boolean; }; +type TransactionListItemData = {type: 'section-header'; groupKey: string; group: OnyxTypes.GroupedTransactions} | {type: 'transaction'; transaction: TransactionWithOptionalHighlight}; + type ReportScreenNavigationProps = ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; type SortedTransactions = { @@ -597,85 +600,111 @@ function MoneyRequestReportTransactionList({ [groupByOptions, reportLayoutGroupBy, styles, windowHeight, isInLandscapeMode], ); - const isDesktopTableLayout = !shouldUseNarrowLayout; - - const lastTransactionID = useMemo(() => { - const allTransactions = shouldShowGroupedTransactions ? groupedTransactions.flatMap((group) => group.transactions) : resolvedTransactions; - const visibleTransactions = allTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); - return visibleTransactions.at(-1)?.transactionID; - }, [shouldShowGroupedTransactions, groupedTransactions, resolvedTransactions, isOffline]); - - const renderTransactionItem = (transaction: TransactionWithOptionalHighlight) => ( - + const listItems = useMemo(() => { + if (shouldShowGroupedTransactions) { + const items: TransactionListItemData[] = []; + for (const group of groupedTransactions) { + items.push({type: 'section-header', groupKey: group.groupKey, group}); + for (const transaction of group.transactions) { + items.push({type: 'transaction', transaction}); + } + } + return items; + } + return resolvedTransactions.map((transaction) => ({type: 'transaction', transaction})); + }, [shouldShowGroupedTransactions, groupedTransactions, resolvedTransactions]); + + const keyExtractor = useCallback((item: TransactionListItemData) => { + if (item.type === 'section-header') { + return `group-${item.groupKey}`; + } + return item.transaction.transactionID; + }, []); + + const renderItem = useCallback( + ({item}: {item: TransactionListItemData}) => { + if (item.type === 'section-header') { + const selectionState = groupSelectionState.get(item.groupKey) ?? { + isSelected: false, + isIndeterminate: false, + isDisabled: false, + pendingAction: undefined, + }; + return ( + + ); + } + const transaction = item.transaction; + return ( + + ); + }, + [ + groupSelectionState, + report, + currentGroupBy, + isMobileSelectionModeEnabled, + toggleGroupSelection, + highlightedTransactionIDs, + columnsToShow, + policy, + toggleTransaction, + isTransactionSelected, + handleOnPress, + handleLongPress, + dateColumnSize, + amountColumnSize, + taxAmountColumnSize, + newTransactions, + scrollToNewTransaction, + handleArrowRightPress, + nonPersonalAndWorkspaceCards, + ], ); - const transactionItems = shouldShowGroupedTransactions - ? groupedTransactions.map((group) => { - const selectionState = groupSelectionState.get(group.groupKey) ?? { - isSelected: false, - isIndeterminate: false, - isDisabled: false, - pendingAction: undefined, - }; - return ( - - - {group.transactions.map((transaction) => renderTransactionItem(transaction))} - - ); - }) - : resolvedTransactions.map((transaction) => renderTransactionItem(transaction)); - - const narrowListWrapper = shouldUseNarrowLayout ? [styles.highlightBG, styles.tableTopRadius, styles.tableBottomRadius, styles.overflowHidden] : undefined; + const listHeight = Math.min(listItems.length * 65, windowHeight * 0.65); const transactionListContent = ( - {narrowListWrapper ? {transactionItems} : transactionItems} - {showPendingExpensePlaceholder && ( - + - )} + ); From f68b1ee8051ccc1027a44231f24c31e1f6673764 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 22 May 2026 13:39:01 +0200 Subject: [PATCH 02/31] perf: unify transactions + actions into one virtualised list --- .../MoneyRequestReportActionsList.tsx | 141 ++++++---- .../MoneyRequestReportTransactionList.tsx | 260 +++++++++++------- 2 files changed, 255 insertions(+), 146 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 336674915180..1312554e6229 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -60,6 +60,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; @@ -78,6 +79,11 @@ const DELAY_FOR_SCROLLING_TO_END = 100; // when the initial load is truncated, so skip backfill for smaller reports. const BACKFILL_MIN_ACTIONS_THRESHOLD = 50; +/** Single virtualised data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; + +const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; + type MoneyRequestReportListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; @@ -547,18 +553,18 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) } }, [visibleReportActions, lastActionEventId, enableScrollToEnd, reportScrollManager]); - const renderItem = useCallback( - ({item: reportAction, index}: ListRenderItemInfo) => { + const renderReportAction = useCallback( + (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { const displayAsGroup = - !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - hasNextActionMadeBySameActor(visibleReportActions, index, isOffline); + !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, indexWithinReportActions, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); return ( item.reportActionID, []); + const keyExtractor = useCallback((item: UnifiedListItem) => { + switch (item.type) { + case 'section-header': + return `group-${item.groupKey}`; + case 'transaction': + return item.transaction.transactionID; + case 'transactions-footer': + return 'transactions-footer'; + case 'report-action': + return item.action.reportActionID; + default: + return ''; + } + }, []); const {windowHeight} = useWindowDimensions(); /** @@ -692,47 +711,77 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) )} {!isReportEmpty && ( - - - 0} - isLoadingInitialReportActions={showReportActionsLoadingState} + 0} + isLoadingInitialReportActions={showReportActionsLoadingState} + render={(controller: MoneyRequestReportTransactionListController) => { + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); + const data: UnifiedListItem[] = controller.isEmptyTransactions + ? reportActionItems + : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + const lastTransactionItemIndex = controller.transactionListItems.length - 1; + const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + switch (item.type) { + case 'section-header': + case 'transaction': + return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); + case 'transactions-footer': + return controller.afterListContent; + case 'report-action': + return renderReportAction(item.action, index - reportActionIndexOffset); + default: + return null; + } + }; + + const listElement = ( + + initialNumToRender={initialNumToRender} + accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} + testID="money-request-report-actions-list" + style={styles.overscrollBehaviorContain} + data={data} + renderItem={dispatchRenderItem} + onViewableItemsChanged={onViewableItemsChanged} + keyExtractor={keyExtractor} + onLayout={recordTimeToMeasureItemLayout} + onEndReached={onEndReached} + onEndReachedThreshold={0.75} + onStartReached={onStartReached} + onStartReachedThreshold={0.75} + ListHeaderComponent={ + <> + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={trackVerticalScrolling} + contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} + ref={reportScrollManager.ref} + ListEmptyComponent={ + !isOffline && showReportActionsLoadingState ? : undefined + } + removeClippedSubviews={false} + initialScrollKey={linkedReportActionID} /> - - } - keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} - contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]} - ref={reportScrollManager.ref} - ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState - removeClippedSubviews={false} - initialScrollKey={linkedReportActionID} + ); + + return controller.wrapWithHorizontalScroll(listElement); + }} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 02c2f7ac51db..726a57ecb302 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,10 +1,9 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import {FlashList} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -// ScrollView type is needed for the horizontal scroll ref; the project ScrollView component is used for rendering. +// ScrollView type is needed for the horizontal scroll ref exposed via the controller; the parent owns the ScrollView component. // eslint-disable-next-line no-restricted-imports import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; import Button from '@components/Button'; @@ -87,6 +86,42 @@ import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyS const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; +type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { + /** Whether the transaction should be highlighted, when it is added to the report */ + shouldBeHighlighted?: boolean; +}; + +type TransactionListItemData = {type: 'section-header'; groupKey: string; group: OnyxTypes.GroupedTransactions} | {type: 'transaction'; transaction: TransactionWithOptionalHighlight}; + +/** + * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. + * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent + * FlatList that virtualises both transactions and report actions in one scroll. Splitting would just smear the + * same locals across multiple call sites without earning an abstraction. + */ +type MoneyRequestReportTransactionListController = { + /** Chrome rendered above the transaction items (group-by dropdown, columns button, table header, or empty state). */ + beforeListContent: React.ReactElement; + + /** Flat array of items to render between beforeListContent and afterListContent. */ + transactionListItems: TransactionListItemData[]; + + /** Render a single transaction-list item. */ + renderTransactionListItem: (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Key extractor for transaction-list items. */ + getTransactionListItemKey: (item: TransactionListItemData) => string; + + /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total, modal). Null when there are no transactions. */ + afterListContent: React.ReactElement | null; + + /** Wrap the unified list element in a horizontal ScrollView when the table is wider than the viewport, otherwise return it unchanged. */ + wrapWithHorizontalScroll: (listElement: React.ReactElement) => React.ReactElement; + + /** True when this report has no transactions; the parent should still render report actions but skip the transactions section. */ + isEmptyTransactions: boolean; +}; + type MoneyRequestReportTransactionListProps = { /** The money request report containing the transactions */ report: OnyxTypes.Report; @@ -117,15 +152,11 @@ type MoneyRequestReportTransactionListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; -}; -type TransactionWithOptionalHighlight = OnyxTypes.Transaction & { - /** Whether the transaction should be highlighted, when it is added to the report */ - shouldBeHighlighted?: boolean; + /** Render-prop the parent uses to assemble its unified FlatList around the transaction-list controller. */ + render: (controller: MoneyRequestReportTransactionListController) => React.ReactNode; }; -type TransactionListItemData = {type: 'section-header'; groupKey: string; group: OnyxTypes.GroupedTransactions} | {type: 'transaction'; transaction: TransactionWithOptionalHighlight}; - type ReportScreenNavigationProps = ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; type SortedTransactions = { @@ -144,6 +175,7 @@ function MoneyRequestReportTransactionList({ hasComments, onLayout, isLoadingInitialReportActions = false, + render, }: MoneyRequestReportTransactionListProps) { useCopySelectionHelper(); const {convertToDisplayString} = useCurrencyListActions(); @@ -621,8 +653,12 @@ function MoneyRequestReportTransactionList({ return item.transaction.transactionID; }, []); - const renderItem = useCallback( - ({item}: {item: TransactionListItemData}) => { + const renderTransactionListItem = useCallback( + (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { + const narrowSectionWrapperStyle = shouldUseNarrowLayout + ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] + : undefined; + if (item.type === 'section-header') { const selectionState = groupSelectionState.get(item.groupKey) ?? { isSelected: false, @@ -631,40 +667,51 @@ function MoneyRequestReportTransactionList({ pendingAction: undefined, }; return ( - + + + + + ); } const transaction = item.transaction; return ( - + + + + + ); }, [ @@ -687,27 +734,17 @@ function MoneyRequestReportTransactionList({ scrollToNewTransaction, handleArrowRightPress, nonPersonalAndWorkspaceCards, + showPendingExpensePlaceholder, + lastTransactionID, + shouldScrollHorizontally, + styles.highlightBG, + styles.tableTopRadius, + styles.tableBottomRadius, + styles.overflowHidden, + styles.ph5, ], ); - const listHeight = Math.min(listItems.length * 65, windowHeight * 0.65); - - const transactionListContent = ( - - - - - - ); - const tableHeaderContent = ( ); - if (isEmptyTransactions) { - return ( - <> - - - - ); - } - - return ( + const beforeListContent = isEmptyTransactions ? ( <> + + + + ) : ( + {shouldShowGroupedTransactions && ( )} - {!shouldUseNarrowLayout && !shouldScrollHorizontally && tableHeaderContent} - {shouldScrollHorizontally ? ( - - - {tableHeaderContent} - {transactionListContent} - - - ) : ( - transactionListContent + {!shouldUseNarrowLayout && tableHeaderContent} + + ); + + const afterListContent = isEmptyTransactions ? null : ( + + {showPendingExpensePlaceholder && ( + + + )} - + ); + + const wrapWithHorizontalScroll = (listElement: React.ReactElement): React.ReactElement => { + if (!shouldScrollHorizontally) { + return listElement; + } + return ( + + {listElement} + + ); + }; + + return render({ + beforeListContent, + transactionListItems: isEmptyTransactions ? [] : listItems, + renderTransactionListItem, + getTransactionListItemKey: keyExtractor, + afterListContent, + wrapWithHorizontalScroll, + isEmptyTransactions, + }); } export default memo(MoneyRequestReportTransactionList); -export type {TransactionWithOptionalHighlight}; +export type {TransactionWithOptionalHighlight, TransactionListItemData, MoneyRequestReportTransactionListController}; From d64ff24e72e8e6ca44ab550884a4894b77a3cc84 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 22 May 2026 14:20:09 +0200 Subject: [PATCH 03/31] fix: restore lastTransactionID and isDesktopTableLayout --- .../MoneyRequestReportTransactionList.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 726a57ecb302..5d328dddc86c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -632,6 +632,14 @@ function MoneyRequestReportTransactionList({ [groupByOptions, reportLayoutGroupBy, styles, windowHeight, isInLandscapeMode], ); + const isDesktopTableLayout = !shouldUseNarrowLayout; + + const lastTransactionID = useMemo(() => { + const allTransactions = shouldShowGroupedTransactions ? groupedTransactions.flatMap((group) => group.transactions) : resolvedTransactions; + const visibleTransactions = allTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); + return visibleTransactions.at(-1)?.transactionID; + }, [shouldShowGroupedTransactions, groupedTransactions, resolvedTransactions, isOffline]); + const listItems = useMemo(() => { if (shouldShowGroupedTransactions) { const items: TransactionListItemData[] = []; From f4fbe38fb184689e6e63ff54fb753978c2e10cfe Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 09:14:39 +0200 Subject: [PATCH 04/31] fix: CI spellcheck and remove memoization wrappers --- .../MoneyRequestReportActionsList.tsx | 2 +- .../MoneyRequestReportTransactionList.tsx | 163 +++++++----------- 2 files changed, 67 insertions(+), 98 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 1312554e6229..3e18806d3519 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -79,7 +79,7 @@ const DELAY_FOR_SCROLLING_TO_END = 100; // when the initial load is truncated, so skip backfill for smaller reports. const BACKFILL_MIN_ACTIONS_THRESHOLD = 50; -/** Single virtualised data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ +/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 5d328dddc86c..55380b060b47 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -96,7 +96,7 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group: /** * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent - * FlatList that virtualises both transactions and report actions in one scroll. Splitting would just smear the + * FlatList that virtualizes both transactions and report actions in one scroll. Splitting would just smear the * same locals across multiple call sites without earning an abstraction. */ type MoneyRequestReportTransactionListController = { @@ -640,118 +640,87 @@ function MoneyRequestReportTransactionList({ return visibleTransactions.at(-1)?.transactionID; }, [shouldShowGroupedTransactions, groupedTransactions, resolvedTransactions, isOffline]); - const listItems = useMemo(() => { - if (shouldShowGroupedTransactions) { - const items: TransactionListItemData[] = []; - for (const group of groupedTransactions) { - items.push({type: 'section-header', groupKey: group.groupKey, group}); - for (const transaction of group.transactions) { - items.push({type: 'transaction', transaction}); - } + const listItems: TransactionListItemData[] = []; + if (shouldShowGroupedTransactions) { + for (const group of groupedTransactions) { + listItems.push({type: 'section-header', groupKey: group.groupKey, group}); + for (const transaction of group.transactions) { + listItems.push({type: 'transaction', transaction}); } - return items; } - return resolvedTransactions.map((transaction) => ({type: 'transaction', transaction})); - }, [shouldShowGroupedTransactions, groupedTransactions, resolvedTransactions]); + } else { + for (const transaction of resolvedTransactions) { + listItems.push({type: 'transaction', transaction}); + } + } - const keyExtractor = useCallback((item: TransactionListItemData) => { + const keyExtractor = (item: TransactionListItemData) => { if (item.type === 'section-header') { return `group-${item.groupKey}`; } return item.transaction.transactionID; - }, []); + }; - const renderTransactionListItem = useCallback( - (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { - const narrowSectionWrapperStyle = shouldUseNarrowLayout - ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] - : undefined; - - if (item.type === 'section-header') { - const selectionState = groupSelectionState.get(item.groupKey) ?? { - isSelected: false, - isIndeterminate: false, - isDisabled: false, - pendingAction: undefined, - }; - return ( - - - - - - ); - } - const transaction = item.transaction; + const renderTransactionListItem = (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { + const narrowSectionWrapperStyle = shouldUseNarrowLayout + ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] + : undefined; + + if (item.type === 'section-header') { + const selectionState = groupSelectionState.get(item.groupKey) ?? { + isSelected: false, + isIndeterminate: false, + isDisabled: false, + pendingAction: undefined, + }; return ( - ); - }, - [ - groupSelectionState, - report, - currentGroupBy, - isMobileSelectionModeEnabled, - toggleGroupSelection, - highlightedTransactionIDs, - columnsToShow, - policy, - toggleTransaction, - isTransactionSelected, - handleOnPress, - handleLongPress, - dateColumnSize, - amountColumnSize, - taxAmountColumnSize, - newTransactions, - scrollToNewTransaction, - handleArrowRightPress, - nonPersonalAndWorkspaceCards, - showPendingExpensePlaceholder, - lastTransactionID, - shouldScrollHorizontally, - styles.highlightBG, - styles.tableTopRadius, - styles.tableBottomRadius, - styles.overflowHidden, - styles.ph5, - ], - ); + } + const transaction = item.transaction; + return ( + + + + + + ); + }; const tableHeaderContent = ( From 1ee223b6c9074a83548894c1cabb34225483eb8a Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 09:27:06 +0200 Subject: [PATCH 05/31] fix: rephrase virtualized comment to satisfy cspell --- .../MoneyRequestReportTransactionList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 55380b060b47..dbff8507a6c4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -96,7 +96,7 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group: /** * Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state. * Wide on purpose: this is the single integration point between TransactionList's internal state and the parent - * FlatList that virtualizes both transactions and report actions in one scroll. Splitting would just smear the + * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the * same locals across multiple call sites without earning an abstraction. */ type MoneyRequestReportTransactionListController = { From 52ceec0f116d46a3b615e5faa8a2925d7de9ed4d Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 11:24:05 +0200 Subject: [PATCH 06/31] use Flashlist --- .../MoneyRequestReportActionsList.tsx | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 3e18806d3519..5d47ee40ce46 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,11 +1,12 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; +import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; +import {FlashList} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; -import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -23,7 +24,6 @@ import useReportTransactionsCollection from '@hooks/useReportTransactionsCollect import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import DateUtils from '@libs/DateUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -49,7 +49,6 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan import Visibility from '@libs/Visibility'; import isSearchTopmostFullScreenRoute from '@navigation/helpers/isSearchTopmostFullScreenRoute'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import getInitialNumToRender from '@pages/inbox/report/getInitialNumReportActionsToRender'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; @@ -656,20 +655,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) } }, []); - const {windowHeight} = useWindowDimensions(); - /** - * Calculates the ideal number of report actions to render in the first render, based on the screen height and on - * the height of the smallest report action possible. - */ - const initialNumToRender = useMemo((): number | undefined => { - const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight; - const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight); - const numToRender = Math.ceil(availableHeight / minimumReportActionHeight); - if (linkedReportActionID) { - return getInitialNumToRender(numToRender); - } - return numToRender || undefined; - }, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]); + const getItemType = (item: UnifiedListItem) => item.type; const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState; const showEmptyState = isReportEmpty; @@ -696,7 +682,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) /> {/* Exactly one of these two branches is active at a time: 1. showEmptyState — genuinely empty report - 2. !isReportEmpty — report has data, render the FlatList */} + 2. !isReportEmpty — report has data, render the FlashList */} {showEmptyState && ( item.type === 'report-action' && item.action.reportActionID === linkedReportActionID) + : -1; + const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; + const listElement = ( - - initialNumToRender={initialNumToRender} + + ref={reportScrollManager.ref as unknown as React.Ref>} accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} testID="money-request-report-actions-list" - style={styles.overscrollBehaviorContain} data={data} renderItem={dispatchRenderItem} - onViewableItemsChanged={onViewableItemsChanged} keyExtractor={keyExtractor} + getItemType={getItemType} + initialScrollIndex={initialScrollIndex} + onViewableItemsChanged={onViewableItemsChanged} onLayout={recordTimeToMeasureItemLayout} onEndReached={onEndReached} - onEndReachedThreshold={0.75} onStartReached={onStartReached} - onStartReachedThreshold={0.75} ListHeaderComponent={ <> : undefined } - removeClippedSubviews={false} - initialScrollKey={linkedReportActionID} /> ); From 2779f709f6b3a79d63b6a43c5f4f38ec2a545ac3 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 12:08:51 +0200 Subject: [PATCH 07/31] refactor: extract unified list render-prop body into sub-component --- .../MoneyRequestReportActionsList.tsx | 213 +++++++++++------- 1 file changed, 133 insertions(+), 80 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 5d47ee40ce46..64dd0ee15808 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -4,7 +4,7 @@ import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; import {FlashList} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -83,6 +83,25 @@ type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-f const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; +function unifiedListKeyExtractor(item: UnifiedListItem) { + switch (item.type) { + case 'section-header': + return `group-${item.groupKey}`; + case 'transaction': + return item.transaction.transactionID; + case 'transactions-footer': + return 'transactions-footer'; + case 'report-action': + return item.action.reportActionID; + default: + return ''; + } +} + +function unifiedListItemType(item: UnifiedListItem) { + return item.type; +} + type MoneyRequestReportListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; @@ -639,24 +658,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) markOpenReportEnd(report, {warm: !shouldShowOpenReportLoadingSkeleton}); }, [report, shouldShowOpenReportLoadingSkeleton]); - // Wrapped into useCallback to stabilize children re-renders - const keyExtractor = useCallback((item: UnifiedListItem) => { - switch (item.type) { - case 'section-header': - return `group-${item.groupKey}`; - case 'transaction': - return item.transaction.transactionID; - case 'transactions-footer': - return 'transactions-footer'; - case 'report-action': - return item.action.reportActionID; - default: - return ''; - } - }, []); - - const getItemType = (item: UnifiedListItem) => item.type; - const isReportEmpty = isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState; const showEmptyState = isReportEmpty; @@ -708,67 +709,27 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) policy={policy} hasComments={visibleReportActions.length > 0} isLoadingInitialReportActions={showReportActionsLoadingState} - render={(controller: MoneyRequestReportTransactionListController) => { - const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); - const data: UnifiedListItem[] = controller.isEmptyTransactions - ? reportActionItems - : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; - const lastTransactionItemIndex = controller.transactionListItems.length - 1; - const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; - - const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { - switch (item.type) { - case 'section-header': - case 'transaction': - return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); - case 'transactions-footer': - return controller.afterListContent; - case 'report-action': - return renderReportAction(item.action, index - reportActionIndexOffset); - default: - return null; - } - }; - - const linkedActionIndex = linkedReportActionID - ? data.findIndex((item) => item.type === 'report-action' && item.action.reportActionID === linkedReportActionID) - : -1; - const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; - - const listElement = ( - - ref={reportScrollManager.ref as unknown as React.Ref>} - accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} - testID="money-request-report-actions-list" - data={data} - renderItem={dispatchRenderItem} - keyExtractor={keyExtractor} - getItemType={getItemType} - initialScrollIndex={initialScrollIndex} - onViewableItemsChanged={onViewableItemsChanged} - onLayout={recordTimeToMeasureItemLayout} - onEndReached={onEndReached} - onStartReached={onStartReached} - ListHeaderComponent={ - <> - - {controller.beforeListContent} - - } - keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} - contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} - ListEmptyComponent={ - !isOffline && showReportActionsLoadingState ? : undefined - } - /> - ); - - return controller.wrapWithHorizontalScroll(listElement); - }} + render={(controller: MoneyRequestReportTransactionListController) => ( + >} + accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} + onLayout={recordTimeToMeasureItemLayout} + onScroll={trackVerticalScrolling} + onViewableItemsChanged={onViewableItemsChanged} + onEndReached={onEndReached} + onStartReached={onStartReached} + contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} + isOffline={isOffline} + isLoadingInitialActions={!!showReportActionsLoadingState} + skeletonReasonAttributes={skeletonReasonAttributes} + /> + )} /> )} @@ -776,4 +737,96 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) ); } +type MoneyRequestReportUnifiedListProps = { + controller: MoneyRequestReportTransactionListController; + report: OnyxTypes.Report; + policy?: OnyxTypes.Policy; + visibleReportActions: OnyxTypes.ReportAction[]; + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + linkedReportActionID: string | undefined; + listRef: React.Ref>; + accessibilityLabel: string; + onLayout: () => void; + onScroll: (event: NativeSyntheticEvent) => void; + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onEndReached: () => void; + onStartReached: () => void; + contentContainerStyle: StyleProp; + isOffline: boolean; + isLoadingInitialActions: boolean; + skeletonReasonAttributes: SkeletonSpanReasonAttributes; +}; + +function MoneyRequestReportUnifiedList({ + controller, + report, + policy, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + accessibilityLabel, + onLayout, + onScroll, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isOffline, + isLoadingInitialActions, + skeletonReasonAttributes, +}: MoneyRequestReportUnifiedListProps) { + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); + const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + const lastTransactionItemIndex = controller.transactionListItems.length - 1; + const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + switch (item.type) { + case 'section-header': + case 'transaction': + return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); + case 'transactions-footer': + return controller.afterListContent; + case 'report-action': + return renderReportAction(item.action, index - reportActionIndexOffset); + default: + return null; + } + }; + + const linkedActionIndex = linkedReportActionID ? data.findIndex((item) => item.type === 'report-action' && item.action.reportActionID === linkedReportActionID) : -1; + const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; + + return controller.wrapWithHorizontalScroll( + + ref={listRef} + accessibilityLabel={accessibilityLabel} + testID="money-request-report-actions-list" + data={data} + renderItem={dispatchRenderItem} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + initialScrollIndex={initialScrollIndex} + onViewableItemsChanged={onViewableItemsChanged} + onLayout={onLayout} + onEndReached={onEndReached} + onStartReached={onStartReached} + ListHeaderComponent={ + <> + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + />, + ); +} + export default MoneyRequestReportActionsList; From 71af131f1cf79e8e69dbd857aa4d7bc4994634eb Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 13:16:30 +0200 Subject: [PATCH 08/31] fix: hoist long-press modal out of virtualised footer cell --- .../MoneyRequestReportActionsList.tsx | 61 ++++++++++--------- .../MoneyRequestReportTransactionList.tsx | 49 +++++++++------ 2 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 64dd0ee15808..d852724c20df 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -798,34 +798,39 @@ function MoneyRequestReportUnifiedList({ const linkedActionIndex = linkedReportActionID ? data.findIndex((item) => item.type === 'report-action' && item.action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; - return controller.wrapWithHorizontalScroll( - - ref={listRef} - accessibilityLabel={accessibilityLabel} - testID="money-request-report-actions-list" - data={data} - renderItem={dispatchRenderItem} - keyExtractor={unifiedListKeyExtractor} - getItemType={unifiedListItemType} - initialScrollIndex={initialScrollIndex} - onViewableItemsChanged={onViewableItemsChanged} - onLayout={onLayout} - onEndReached={onEndReached} - onStartReached={onStartReached} - ListHeaderComponent={ - <> - - {controller.beforeListContent} - - } - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - contentContainerStyle={contentContainerStyle} - ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} - />, + return ( + <> + {controller.wrapWithHorizontalScroll( + + ref={listRef} + accessibilityLabel={accessibilityLabel} + testID="money-request-report-actions-list" + data={data} + renderItem={dispatchRenderItem} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + initialScrollIndex={initialScrollIndex} + onViewableItemsChanged={onViewableItemsChanged} + onLayout={onLayout} + onEndReached={onEndReached} + onStartReached={onStartReached} + ListHeaderComponent={ + <> + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + />, + )} + {controller.longPressModal} + ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index dbff8507a6c4..f753d3b47492 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -112,9 +112,16 @@ type MoneyRequestReportTransactionListController = { /** Key extractor for transaction-list items. */ getTransactionListItemKey: (item: TransactionListItemData) => string; - /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total, modal). Null when there are no transactions. */ + /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; + /** + * Long-press mobile selection modal. Rendered as a sibling of the FlashList rather than inside `afterListContent` + * because FlashList recycles the footer cell out of the React tree when scrolled away, which would prevent the + * modal from portalling. Always mounted. + */ + longPressModal: React.ReactElement; + /** Wrap the unified list element in a horizontal ScrollView when the table is wider than the viewport, otherwise return it unchanged. */ wrapWithHorizontalScroll: (listElement: React.ReactElement) => React.ReactElement; @@ -927,27 +934,30 @@ function MoneyRequestReportTransactionList({ - setIsModalVisible(false)} - shouldPreventScrollOnFocus - > - { - if (!isMobileSelectionModeEnabled) { - turnOnMobileSelectionMode(); - } - toggleTransaction(selectedTransactionID); - setIsModalVisible(false); - }} - /> - ); + const longPressModal = ( + setIsModalVisible(false)} + shouldPreventScrollOnFocus + > + { + if (!isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleTransaction(selectedTransactionID); + setIsModalVisible(false); + }} + /> + + ); + const wrapWithHorizontalScroll = (listElement: React.ReactElement): React.ReactElement => { if (!shouldScrollHorizontally) { return listElement; @@ -973,6 +983,7 @@ function MoneyRequestReportTransactionList({ renderTransactionListItem, getTransactionListItemKey: keyExtractor, afterListContent, + longPressModal, wrapWithHorizontalScroll, isEmptyTransactions, }); From 1220e393be9352c967846dd4169ee9e0dfa55046 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 25 May 2026 14:34:40 +0200 Subject: [PATCH 09/31] refactor: extract long-press modal and horizontal scroll wrapper components --- .../MoneyRequestReportActionsList.tsx | 66 +++++----- ...eyRequestReportHorizontalScrollWrapper.tsx | 52 ++++++++ .../MoneyRequestReportTransactionList.tsx | 117 +++++------------- ...RequestReportTransactionLongPressModal.tsx | 56 +++++++++ 4 files changed, 175 insertions(+), 116 deletions(-) create mode 100644 src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx create mode 100644 src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index d852724c20df..ae71ed4f21b0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -59,6 +59,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import MoneyRequestReportHorizontalScrollWrapper from './MoneyRequestReportHorizontalScrollWrapper'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; @@ -799,38 +800,39 @@ function MoneyRequestReportUnifiedList({ const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; return ( - <> - {controller.wrapWithHorizontalScroll( - - ref={listRef} - accessibilityLabel={accessibilityLabel} - testID="money-request-report-actions-list" - data={data} - renderItem={dispatchRenderItem} - keyExtractor={unifiedListKeyExtractor} - getItemType={unifiedListItemType} - initialScrollIndex={initialScrollIndex} - onViewableItemsChanged={onViewableItemsChanged} - onLayout={onLayout} - onEndReached={onEndReached} - onStartReached={onStartReached} - ListHeaderComponent={ - <> - - {controller.beforeListContent} - - } - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - contentContainerStyle={contentContainerStyle} - ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} - />, - )} - {controller.longPressModal} - + + + ref={listRef} + accessibilityLabel={accessibilityLabel} + testID="money-request-report-actions-list" + data={data} + renderItem={dispatchRenderItem} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + initialScrollIndex={initialScrollIndex} + onViewableItemsChanged={onViewableItemsChanged} + onLayout={onLayout} + onEndReached={onEndReached} + onStartReached={onStartReached} + ListHeaderComponent={ + <> + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + /> + ); } diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx new file mode 100644 index 000000000000..065c6393447f --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx @@ -0,0 +1,52 @@ +import React, {useCallback, useLayoutEffect, useRef} from 'react'; +// ScrollView component needed for horizontal table scroll on wide layouts; vertical scroll happens in the parent FlashList. +// eslint-disable-next-line no-restricted-imports +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; +import ScrollView from '@components/ScrollView'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type MoneyRequestReportHorizontalScrollWrapperProps = { + shouldScroll: boolean; + contentWidth: number; + /** When this value changes, the wrapper restores the previous horizontal scroll offset synchronously before paint. */ + restorationKey: unknown; + children: React.ReactElement; +}; + +function MoneyRequestReportHorizontalScrollWrapper({shouldScroll, contentWidth, restorationKey, children}: MoneyRequestReportHorizontalScrollWrapperProps) { + const styles = useThemeStyles(); + const scrollRef = useRef(null); + const offsetRef = useRef(0); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + offsetRef.current = event.nativeEvent.contentOffset.x; + }, []); + + useLayoutEffect(() => { + if (!shouldScroll || offsetRef.current <= 0) { + return; + } + scrollRef.current?.scrollTo({x: offsetRef.current, animated: false}); + }, [restorationKey, shouldScroll]); + + if (!shouldScroll) { + return children; + } + + return ( + + {children} + + ); +} + +export default MoneyRequestReportHorizontalScrollWrapper; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 86c5c0273a72..1b96b561413c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,18 +1,13 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import isEmpty from 'lodash/isEmpty'; -import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -// ScrollView type is needed for the horizontal scroll ref exposed via the controller; the parent owns the ScrollView component. -// eslint-disable-next-line no-restricted-imports -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView as RNScrollView} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; -import MenuItem from '@components/MenuItem'; -import Modal from '@components/Modal'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScrollView from '@components/ScrollView'; import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds, SortOrder} from '@components/Search/types'; @@ -37,7 +32,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {setOptimisticTransactionThread} from '@libs/actions/Report'; import {getReportLayoutGroupBy, setReportLayoutGroupBy} from '@libs/actions/ReportLayout'; import {clearActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; @@ -82,6 +76,8 @@ import MoneyRequestReportGroupHeader from './MoneyRequestReportGroupHeader'; import MoneyRequestReportTableHeader from './MoneyRequestReportTableHeader'; import MoneyRequestReportTotalSpend from './MoneyRequestReportTotalSpend'; import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionItem'; +import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; +import type {MoneyRequestReportTransactionLongPressModalHandle} from './MoneyRequestReportTransactionLongPressModal'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; @@ -115,15 +111,14 @@ type MoneyRequestReportTransactionListController = { /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; - /** - * Long-press mobile selection modal. Rendered as a sibling of the FlashList rather than inside `afterListContent` - * because FlashList recycles the footer cell out of the React tree when scrolled away, which would prevent the - * modal from portalling. Always mounted. - */ - longPressModal: React.ReactElement; + /** True when the rendered table is wider than the viewport; the parent should wrap the list in `MoneyRequestReportHorizontalScrollWrapper`. */ + shouldScrollHorizontally: boolean; + + /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ + tableMinWidth: number; - /** Wrap the unified list element in a horizontal ScrollView when the table is wider than the viewport, otherwise return it unchanged. */ - wrapWithHorizontalScroll: (listElement: React.ReactElement) => React.ReactElement; + /** Token that changes when the rendered list content changes; the horizontal scroll wrapper uses it to restore the previous offset. */ + horizontalScrollRestorationKey: unknown; /** True when this report has no transactions; the parent should still render report actions but skip the transactions section. */ isEmptyTransactions: boolean; @@ -189,14 +184,13 @@ function MoneyRequestReportTransactionList({ const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'CheckSquare', 'ReceiptPlus', 'Columns', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Columns', 'Plus']); const {translate, localeCompare} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP(); const {markReportIDAsExpense} = useWideRHPActions(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [selectedTransactionID, setSelectedTransactionID] = useState(''); + const longPressModalRef = useRef(null); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); const {isOffline} = useNetwork(); @@ -392,20 +386,6 @@ function MoneyRequestReportTransactionList({ const {windowWidth, windowHeight} = useWindowDimensions(); const minTableWidth = getTableMinWidth(columnsToShow); const shouldScrollHorizontally = !shouldUseNarrowLayout && minTableWidth > windowWidth; - const horizontalScrollViewRef = useRef(null); - const horizontalScrollOffsetRef = useRef(0); - - const handleHorizontalScroll = useCallback((event: NativeSyntheticEvent) => { - horizontalScrollOffsetRef.current = event.nativeEvent.contentOffset.x; - }, []); - - // Restore horizontal scroll position synchronously before paint when transactions change - useLayoutEffect(() => { - if (!shouldScrollHorizontally || horizontalScrollOffsetRef.current <= 0) { - return; - } - horizontalScrollViewRef.current?.scrollTo({x: horizontalScrollOffsetRef.current, animated: false}); - }, [sortedTransactions, shouldScrollHorizontally]); const currentGroupBy = getReportLayoutGroupBy(reportLayoutGroupBy); @@ -559,10 +539,9 @@ function MoneyRequestReportTransactionList({ toggleTransaction(transactionID); return; } - setSelectedTransactionID(transactionID); - setIsModalVisible(true); + longPressModalRef.current?.show(transactionID); }, - [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction, setSelectedTransactionID, setIsModalVisible], + [isSmallScreenWidth, isMobileSelectionModeEnabled, toggleTransaction], ); const handleOnPress = useCallback( @@ -937,56 +916,26 @@ function MoneyRequestReportTransactionList({ ); - const longPressModal = ( - setIsModalVisible(false)} - shouldPreventScrollOnFocus - > - { - if (!isMobileSelectionModeEnabled) { - turnOnMobileSelectionMode(); - } - toggleTransaction(selectedTransactionID); - setIsModalVisible(false); - }} + return ( + <> + {render({ + beforeListContent, + transactionListItems: isEmptyTransactions ? [] : listItems, + renderTransactionListItem, + getTransactionListItemKey: keyExtractor, + afterListContent, + shouldScrollHorizontally, + tableMinWidth: minTableWidth, + horizontalScrollRestorationKey: sortedTransactions, + isEmptyTransactions, + })} + - + ); - - const wrapWithHorizontalScroll = (listElement: React.ReactElement): React.ReactElement => { - if (!shouldScrollHorizontally) { - return listElement; - } - return ( - - {listElement} - - ); - }; - - return render({ - beforeListContent, - transactionListItems: isEmptyTransactions ? [] : listItems, - renderTransactionListItem, - getTransactionListItemKey: keyExtractor, - afterListContent, - longPressModal, - wrapWithHorizontalScroll, - isEmptyTransactions, - }); } export default memo(MoneyRequestReportTransactionList); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx new file mode 100644 index 000000000000..e92ab4cf453b --- /dev/null +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx @@ -0,0 +1,56 @@ +import React, {useImperativeHandle, useState} from 'react'; +import type {Ref} from 'react'; +import MenuItem from '@components/MenuItem'; +import Modal from '@components/Modal'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import CONST from '@src/CONST'; + +type MoneyRequestReportTransactionLongPressModalHandle = { + show: (transactionID: string) => void; +}; + +type MoneyRequestReportTransactionLongPressModalProps = { + isMobileSelectionModeEnabled: boolean; + toggleTransaction: (transactionID: string) => void; + ref: Ref; +}; + +function MoneyRequestReportTransactionLongPressModal({isMobileSelectionModeEnabled, toggleTransaction, ref}: MoneyRequestReportTransactionLongPressModalProps) { + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['CheckSquare']); + const [isVisible, setIsVisible] = useState(false); + const [selectedTransactionID, setSelectedTransactionID] = useState(''); + + useImperativeHandle(ref, () => ({ + show: (transactionID: string) => { + setSelectedTransactionID(transactionID); + setIsVisible(true); + }, + })); + + return ( + setIsVisible(false)} + shouldPreventScrollOnFocus + > + { + if (!isMobileSelectionModeEnabled) { + turnOnMobileSelectionMode(); + } + toggleTransaction(selectedTransactionID); + setIsVisible(false); + }} + /> + + ); +} + +export default MoneyRequestReportTransactionLongPressModal; +export type {MoneyRequestReportTransactionLongPressModalHandle}; From 143946e0f8ce097e48bc359e91fd06d512f03881 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 26 May 2026 11:37:28 +0200 Subject: [PATCH 10/31] remove dead keyExtractor from transaction list controller --- .../MoneyRequestReportTransactionList.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 1b96b561413c..21c3107df4a5 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -105,9 +105,6 @@ type MoneyRequestReportTransactionListController = { /** Render a single transaction-list item. */ renderTransactionListItem: (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; - /** Key extractor for transaction-list items. */ - getTransactionListItemKey: (item: TransactionListItemData) => string; - /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; @@ -640,13 +637,6 @@ function MoneyRequestReportTransactionList({ } } - const keyExtractor = (item: TransactionListItemData) => { - if (item.type === 'section-header') { - return `group-${item.groupKey}`; - } - return item.transaction.transactionID; - }; - const renderTransactionListItem = (item: TransactionListItemData, position: {isFirst: boolean; isLast: boolean}) => { const narrowSectionWrapperStyle = shouldUseNarrowLayout ? [styles.highlightBG, position.isFirst && styles.tableTopRadius, position.isLast && styles.tableBottomRadius, (position.isFirst || position.isLast) && styles.overflowHidden] @@ -922,7 +912,6 @@ function MoneyRequestReportTransactionList({ beforeListContent, transactionListItems: isEmptyTransactions ? [] : listItems, renderTransactionListItem, - getTransactionListItemKey: keyExtractor, afterListContent, shouldScrollHorizontally, tableMinWidth: minTableWidth, From 03ce2973caf7c2c00508b8b0f98777a0d20c0caf Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 26 May 2026 11:42:11 +0200 Subject: [PATCH 11/31] fix unread marker index offset when transactions are present --- .../MoneyRequestReportActionsList.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 8e34b0a499ee..2217e1d703e6 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -771,6 +771,20 @@ function MoneyRequestReportUnifiedList({ const lastTransactionItemIndex = controller.transactionListItems.length - 1; const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against + // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. + const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + if (reportActionIndexOffset === 0) { + onViewableItemsChanged(info); + return; + } + onViewableItemsChanged({ + ...info, + viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), + }); + }; + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { switch (item.type) { case 'section-header': @@ -803,7 +817,7 @@ function MoneyRequestReportUnifiedList({ keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={onLayout} onEndReached={onEndReached} onStartReached={onStartReached} From 4af0be015524b6b75ecbe69dbbbced6ba497c3f7 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 26 May 2026 14:40:49 +0200 Subject: [PATCH 12/31] avoid scanning transactions when resolving linked action index --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 2217e1d703e6..b18b40dbb334 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -799,8 +799,8 @@ function MoneyRequestReportUnifiedList({ } }; - const linkedActionIndex = linkedReportActionID ? data.findIndex((item) => item.type === 'report-action' && item.action.reportActionID === linkedReportActionID) : -1; - const initialScrollIndex = linkedActionIndex >= 0 ? linkedActionIndex : undefined; + const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; + const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; return ( Date: Wed, 27 May 2026 10:07:10 +0200 Subject: [PATCH 13/31] Pin scroll to bottom while deferred report items hydrate --- .../MoneyRequestReportActionsList.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index b18b40dbb334..a05c7dfce85c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -218,6 +218,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const scrollingVerticalTopOffset = useRef(0); const wrapperViewRef = useRef(null); const readActionSkipped = useRef(false); + const stickToBottomRef = useRef(false); + const stickToBottomTimeoutRef = useRef(null); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; const userActiveSince = useRef(DateUtils.getDBTime()); @@ -611,6 +613,16 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const scrollToBottomAndMarkReportAsRead = useCallback(() => { setIsFloatingMessageCounterVisible(false); + stickToBottomRef.current = true; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + } + // Safety net: stop pinning after deferred content has had time to settle, so a much later + // unrelated layout change doesn't yank the user back down. + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, 2000); + if (!hasNewestReportAction) { openReport({reportID, introSelected, betas}); reportScrollManager.scrollToEnd(); @@ -622,6 +634,30 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, reportLoadingState?.hasOnceLoadedReportActions, introSelected, betas]); + useEffect(() => { + return () => { + if (!stickToBottomTimeoutRef.current) { + return; + } + clearTimeout(stickToBottomTimeoutRef.current); + }; + }, []); + + const onListContentSizeChange = () => { + if (!stickToBottomRef.current) { + return; + } + reportScrollManager.scrollToEnd(); + }; + + const onListScrollBeginDrag = () => { + stickToBottomRef.current = false; + if (stickToBottomTimeoutRef.current) { + clearTimeout(stickToBottomTimeoutRef.current); + stickToBottomTimeoutRef.current = null; + } + }; + const scrollToNewTransaction = useCallback( (pageY: number) => { wrapperViewRef.current?.measureInWindow((x, y, w, height) => { @@ -711,6 +747,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} onLayout={recordTimeToMeasureItemLayout} onScroll={trackVerticalScrolling} + onScrollBeginDrag={onListScrollBeginDrag} + onContentSizeChange={onListContentSizeChange} onViewableItemsChanged={onViewableItemsChanged} onEndReached={onEndReached} onStartReached={onStartReached} @@ -738,6 +776,8 @@ type MoneyRequestReportUnifiedListProps = { accessibilityLabel: string; onLayout: () => void; onScroll: (event: NativeSyntheticEvent) => void; + onScrollBeginDrag: () => void; + onContentSizeChange: () => void; onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; onEndReached: () => void; onStartReached: () => void; @@ -758,6 +798,8 @@ function MoneyRequestReportUnifiedList({ accessibilityLabel, onLayout, onScroll, + onScrollBeginDrag, + onContentSizeChange, onViewableItemsChanged, onEndReached, onStartReached, @@ -817,6 +859,9 @@ function MoneyRequestReportUnifiedList({ keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} + maintainVisibleContentPosition={{ + autoscrollToBottomThreshold: 0, + }} onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={onLayout} onEndReached={onEndReached} @@ -832,6 +877,8 @@ function MoneyRequestReportUnifiedList({ } keyboardShouldPersistTaps="handled" onScroll={onScroll} + onScrollBeginDrag={onScrollBeginDrag} + onContentSizeChange={onContentSizeChange} contentContainerStyle={contentContainerStyle} ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} /> From 25e7c49001b1233566fed963f5dc67a41b8a3914 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 28 May 2026 13:54:48 +0200 Subject: [PATCH 14/31] allow to skip deferring RBR message to prevent dynamic changes to item height --- .../MoneyRequestReportTransactionItem.tsx | 6 ++++++ .../DeferredTransactionItemRowRBR.tsx | 10 +++++++--- .../TransactionItemRow/TransactionItemRowNarrow.tsx | 7 +++++-- .../TransactionItemRow/TransactionItemRowWide.tsx | 12 +++++++++--- src/components/TransactionItemRow/index.tsx | 4 ++++ src/components/TransactionItemRow/types.ts | 10 +++++++++- 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index d2ef69a1175d..135ebb0fec20 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -87,6 +87,9 @@ type MoneyRequestReportTransactionItemBodyProps = MoneyRequestReportTransactionI /** Highlight animation style, computed by the parent so its state survives the narrow↔wide swap on resize. */ animatedHighlightStyle: ReturnType; + + /** Whether to skip deferring the RBR content. */ + shouldSkipDeferRBR?: boolean; }; function MoneyRequestReportTransactionItemBody({ @@ -110,6 +113,7 @@ function MoneyRequestReportTransactionItemBody({ shouldScrollHorizontally = false, inlineEdit, animatedHighlightStyle, + shouldSkipDeferRBR = false, }: MoneyRequestReportTransactionItemBodyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -220,6 +224,7 @@ function MoneyRequestReportTransactionItemBody({ onEditCategory={inlineEdit?.onEditCategory} onEditAmount={inlineEdit?.onEditAmount} onEditTag={inlineEdit?.onEditTag} + shouldSkipDeferRBR={shouldSkipDeferRBR} /> )} @@ -262,6 +267,7 @@ function MoneyRequestReportTransactionItem(props: MoneyRequestReportTransactionI ); } diff --git a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx index ec1abcd1f7b1..959c4906ea2d 100644 --- a/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx +++ b/src/components/TransactionItemRow/DeferredTransactionItemRowRBR.tsx @@ -1,10 +1,14 @@ import React, {useDeferredValue} from 'react'; import TransactionItemRowRBR from './TransactionItemRowRBR'; -type DeferredTransactionItemRowRBRProps = React.ComponentProps; +type DeferredTransactionItemRowRBRProps = React.ComponentProps & { + /** When false, renders RBR immediately. Use false in FlashList rows to avoid recycling/layout issues from deferred mount. */ + shouldDefer?: boolean; +}; -function DeferredTransactionItemRowRBR(props: DeferredTransactionItemRowRBRProps) { - const shouldRender = useDeferredValue(true, false); +function DeferredTransactionItemRowRBR({shouldDefer = true, ...props}: DeferredTransactionItemRowRBRProps) { + const deferredShouldRender = useDeferredValue(true, false); + const shouldRender = shouldDefer ? deferredShouldRender : true; // Skip placeholder while deferring to avoid layout shift on rows without RBR content if (!shouldRender) { diff --git a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx index 58e4c35036ea..f12dfcda23da 100644 --- a/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowNarrow.tsx @@ -16,7 +16,7 @@ import ReceiptCell from './DataCells/ReceiptCell'; import TotalCell from './DataCells/TotalCell'; import TypeCell from './DataCells/TypeCell'; import DeferredTransactionItemRowRBR from './DeferredTransactionItemRowRBR'; -import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps} from './types'; +import type {TransactionItemRowNarrowComputedData, TransactionItemRowProps, TransactionItemRowRBRDeferControlProps} from './types'; type TransactionItemRowNarrowProps = Pick< TransactionItemRowProps, @@ -38,7 +38,8 @@ type TransactionItemRowNarrowProps = Pick< | 'shouldShowArrowRightOnNarrowLayout' | 'checkboxSentryLabel' > & - TransactionItemRowNarrowComputedData; + TransactionItemRowNarrowComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowNarrow({ transactionItem, @@ -58,6 +59,7 @@ function TransactionItemRowNarrow({ onArrowRightPress, shouldShowArrowRightOnNarrowLayout, checkboxSentryLabel, + shouldDeferRBR = true, bgActiveStyles, merchant, merchantOrDescription, @@ -161,6 +163,7 @@ function TransactionItemRowNarrow({ {shouldShowErrors && ( & - TransactionItemRowWideComputedData; +type TransactionItemRowWideProps = Omit< + TransactionItemRowProps, + 'shouldUseNarrowLayout' | 'isAttendeesEnabledForMovingPolicy' | 'isLargeScreenWidth' | 'shouldShowCheckbox' | 'shouldSkipDeferRBR' +> & + TransactionItemRowWideComputedData & + TransactionItemRowRBRDeferControlProps; function TransactionItemRowWide({ transactionItem, @@ -77,6 +81,7 @@ function TransactionItemRowWide({ shouldShowRadioButton = false, onRadioButtonPress = () => {}, shouldShowErrors = true, + shouldDeferRBR = true, isDisabled = false, violations, shouldShowBottomBorder, @@ -624,6 +629,7 @@ function TransactionItemRowWide({ {shouldShowErrors && ( ); } @@ -242,6 +245,7 @@ function TransactionItemRow({ totalPerAttendee={!attendeesCount || totalAmount === undefined ? undefined : totalAmount / attendeesCount} createdAt={createdAt} transactionThreadReportID={transactionThreadReportID} + shouldDeferRBR={shouldDeferRBR} /> ); } diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 77df4666b994..00524eeaa02e 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -106,6 +106,14 @@ type TransactionItemRowProps = { canEditCategory?: boolean; canEditAmount?: boolean; canEditTag?: boolean; + + /** When true, RBR content renders immediately instead of via useDeferredValue. Use in FlashList contexts. */ + shouldSkipDeferRBR?: boolean; +}; + +/** Derived from shouldSkipDeferRBR; passed to layout variants for DeferredTransactionItemRowRBR. */ +type TransactionItemRowRBRDeferControlProps = { + shouldDeferRBR?: boolean; }; /** @@ -135,4 +143,4 @@ type TransactionItemRowWideComputedData = Omit Date: Thu, 28 May 2026 14:20:04 +0200 Subject: [PATCH 15/31] add drawDistance --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 8b0d4f45650b..f1186e3a4a17 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -885,6 +885,7 @@ function MoneyRequestReportUnifiedList({ onContentSizeChange={onContentSizeChange} contentContainerStyle={contentContainerStyle} ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + drawDistance={1000} /> ); From 7c40dfc39157b9ebfd43a6ba2a50eb06b8a28b7e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Mon, 1 Jun 2026 14:00:43 +0200 Subject: [PATCH 16/31] Extract MoneyRequestReportUnifiedList and remove render-prop inversion --- .../MoneyRequestReportActionsList.tsx | 193 ++---------------- .../MoneyRequestReportTransactionList.tsx | 110 ++++++++-- .../MoneyRequestReportUnifiedList.tsx | 159 +++++++++++++++ 3 files changed, 273 insertions(+), 189 deletions(-) create mode 100644 src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index be65664f0829..034011fa97dc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,10 +1,9 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import {FlashList} from '@shopify/flash-list'; +import type {FlashListRef} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; // eslint-disable-next-line no-restricted-imports import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import ScrollView from '@components/ScrollView'; @@ -60,11 +59,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; -import MoneyRequestReportHorizontalScrollWrapper from './MoneyRequestReportHorizontalScrollWrapper'; -import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; +import type {UnifiedListItem} from './MoneyRequestReportUnifiedList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; -import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import SelectionToolbar from './SelectionToolbar'; @@ -80,30 +77,6 @@ const DELAY_FOR_SCROLLING_TO_END = 100; // when the initial load is truncated, so skip backfill for smaller reports. const BACKFILL_MIN_ACTIONS_THRESHOLD = 50; -/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */ -type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction}; - -const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'}; - -function unifiedListKeyExtractor(item: UnifiedListItem) { - switch (item.type) { - case 'section-header': - return `group-${item.groupKey}`; - case 'transaction': - return item.transaction.transactionID; - case 'transactions-footer': - return 'transactions-footer'; - case 'report-action': - return item.action.reportActionID; - default: - return ''; - } -} - -function unifiedListItemType(item: UnifiedListItem) { - return item.type; -} - type MoneyRequestReportListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; @@ -735,29 +708,21 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) policy={policy} hasComments={visibleReportActions.length > 0} isLoadingInitialReportActions={showReportActionsLoadingState} - render={(controller: MoneyRequestReportTransactionListController) => ( - >} - accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} - onLayout={recordTimeToMeasureItemLayout} - onScroll={trackVerticalScrolling} - onScrollBeginDrag={onListScrollBeginDrag} - onContentSizeChange={onListContentSizeChange} - onViewableItemsChanged={onViewableItemsChanged} - onEndReached={onEndReached} - onStartReached={onStartReached} - contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} - isOffline={isOffline} - isLoadingInitialActions={!!showReportActionsLoadingState} - skeletonReasonAttributes={skeletonReasonAttributes} - /> - )} + visibleReportActions={visibleReportActions} + renderReportAction={renderReportAction} + linkedReportActionID={linkedReportActionID} + listRef={reportScrollManager.ref as unknown as React.Ref>} + accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} + onListLayout={recordTimeToMeasureItemLayout} + onScroll={trackVerticalScrolling} + onScrollBeginDrag={onListScrollBeginDrag} + onContentSizeChange={onListContentSizeChange} + onViewableItemsChanged={onViewableItemsChanged} + onEndReached={onEndReached} + onStartReached={onStartReached} + contentContainerStyle={shouldUseNarrowLayout ? styles.pt4 : styles.pt3} + isLoadingInitialActions={!!showReportActionsLoadingState} + skeletonReasonAttributes={skeletonReasonAttributes} /> )} @@ -765,126 +730,4 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) ); } -type MoneyRequestReportUnifiedListProps = { - controller: MoneyRequestReportTransactionListController; - report: OnyxTypes.Report; - policy?: OnyxTypes.Policy; - visibleReportActions: OnyxTypes.ReportAction[]; - renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; - linkedReportActionID: string | undefined; - listRef: React.Ref>; - accessibilityLabel: string; - onLayout: () => void; - onScroll: (event: NativeSyntheticEvent) => void; - onScrollBeginDrag: () => void; - onContentSizeChange: () => void; - onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; - onEndReached: () => void; - onStartReached: () => void; - contentContainerStyle: StyleProp; - isOffline: boolean; - isLoadingInitialActions: boolean; - skeletonReasonAttributes: SkeletonSpanReasonAttributes; -}; - -function MoneyRequestReportUnifiedList({ - controller, - report, - policy, - visibleReportActions, - renderReportAction, - linkedReportActionID, - listRef, - accessibilityLabel, - onLayout, - onScroll, - onScrollBeginDrag, - onContentSizeChange, - onViewableItemsChanged, - onEndReached, - onStartReached, - contentContainerStyle, - isOffline, - isLoadingInitialActions, - skeletonReasonAttributes, -}: MoneyRequestReportUnifiedListProps) { - const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); - const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; - const lastTransactionItemIndex = controller.transactionListItems.length - 1; - const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; - - // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against - // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, - // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. - const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { - if (reportActionIndexOffset === 0) { - onViewableItemsChanged(info); - return; - } - onViewableItemsChanged({ - ...info, - viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), - }); - }; - - const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { - switch (item.type) { - case 'section-header': - case 'transaction': - return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); - case 'transactions-footer': - return controller.afterListContent; - case 'report-action': - return renderReportAction(item.action, index - reportActionIndexOffset); - default: - return null; - } - }; - - const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; - const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; - - return ( - - - ref={listRef} - accessibilityLabel={accessibilityLabel} - testID="money-request-report-actions-list" - data={data} - renderItem={dispatchRenderItem} - keyExtractor={unifiedListKeyExtractor} - getItemType={unifiedListItemType} - initialScrollIndex={initialScrollIndex} - maintainVisibleContentPosition={{ - autoscrollToBottomThreshold: 0, - }} - onViewableItemsChanged={onViewableItemsChangedAdjusted} - onLayout={onLayout} - onEndReached={onEndReached} - onStartReached={onStartReached} - ListHeaderComponent={ - <> - - {controller.beforeListContent} - - } - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - onScrollBeginDrag={onScrollBeginDrag} - onContentSizeChange={onContentSizeChange} - contentContainerStyle={contentContainerStyle} - ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} - drawDistance={1000} - /> - - ); -} - export default MoneyRequestReportActionsList; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 266771ed3ae6..9b63375f2370 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,9 +1,10 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import type {FlashListRef} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {LayoutChangeEvent} from 'react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; @@ -56,6 +57,7 @@ import { import type {SortableColumnName} from '@libs/ReportUtils'; import {compareValues, getColumnsToShow, getTableMinWidth, hasFlexColumn, isTransactionAmountTooLong, isTransactionTaxAmountTooLong} from '@libs/SearchUIUtils'; import {getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {transactionHasRBR} from '@libs/TransactionPreviewUtils'; import {getTransactionPendingAction, getVisibleTransactionViolations, isTransactionPendingDelete, shouldShowExpenseBreakdown} from '@libs/TransactionUtils'; import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransactionYear'; @@ -78,6 +80,8 @@ import MoneyRequestReportTotalSpend from './MoneyRequestReportTotalSpend'; import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionItem'; import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; import type {MoneyRequestReportTransactionLongPressModalHandle} from './MoneyRequestReportTransactionLongPressModal'; +import MoneyRequestReportUnifiedList from './MoneyRequestReportUnifiedList'; +import type {UnifiedListItem} from './MoneyRequestReportUnifiedList'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; @@ -178,8 +182,50 @@ type MoneyRequestReportTransactionListProps = { /** Callback executed on layout */ onLayout?: (event: LayoutChangeEvent) => void; - /** Render-prop the parent uses to assemble its unified FlatList around the transaction-list controller. */ - render: (controller: MoneyRequestReportTransactionListController) => React.ReactNode; + /** Reversed list of report actions to render below the transactions section in the unified list. */ + visibleReportActions: OnyxTypes.ReportAction[]; + + /** Renders a single report action row in the unified list. */ + renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; + + /** Report action ID the unified list should initially scroll to, when deep-linked. */ + linkedReportActionID: string | undefined; + + /** Ref forwarded to the underlying FlashList. */ + listRef: React.Ref>; + + /** Accessibility label for the unified list. */ + accessibilityLabel: string; + + /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */ + onListLayout: () => void; + + /** FlashList onScroll callback. */ + onScroll: (event: NativeSyntheticEvent) => void; + + /** FlashList onScrollBeginDrag callback. */ + onScrollBeginDrag: () => void; + + /** FlashList onContentSizeChange callback. */ + onContentSizeChange: () => void; + + /** FlashList onViewableItemsChanged callback. */ + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + + /** FlashList onEndReached callback. */ + onEndReached: () => void; + + /** FlashList onStartReached callback. */ + onStartReached: () => void; + + /** FlashList contentContainerStyle. */ + contentContainerStyle: StyleProp; + + /** Whether the initial report actions are still loading. */ + isLoadingInitialActions: boolean; + + /** Reason attributes forwarded to the loading skeleton span. */ + skeletonReasonAttributes: SkeletonSpanReasonAttributes; }; type ReportScreenNavigationProps = ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; @@ -200,7 +246,21 @@ function MoneyRequestReportTransactionList({ hasComments, onLayout, isLoadingInitialReportActions = false, - render, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + accessibilityLabel, + onListLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isLoadingInitialActions, + skeletonReasonAttributes, }: MoneyRequestReportTransactionListProps) { useCopySelectionHelper(); const {convertToDisplayString} = useCurrencyListActions(); @@ -953,18 +1013,40 @@ function MoneyRequestReportTransactionList({ ); + const controller: MoneyRequestReportTransactionListController = { + beforeListContent, + transactionListItems: isEmptyTransactions ? [] : listItems, + renderTransactionListItem, + afterListContent, + shouldScrollHorizontally, + tableMinWidth: minTableWidth, + horizontalScrollRestorationKey: sortedTransactions, + isEmptyTransactions, + }; + return ( <> - {render({ - beforeListContent, - transactionListItems: isEmptyTransactions ? [] : listItems, - renderTransactionListItem, - afterListContent, - shouldScrollHorizontally, - tableMinWidth: minTableWidth, - horizontalScrollRestorationKey: sortedTransactions, - isEmptyTransactions, - })} + React.ReactElement; + linkedReportActionID: string | undefined; + listRef: React.Ref>; + accessibilityLabel: string; + onLayout: () => void; + onScroll: (event: NativeSyntheticEvent) => void; + onScrollBeginDrag: () => void; + onContentSizeChange: () => void; + onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void; + onEndReached: () => void; + onStartReached: () => void; + contentContainerStyle: StyleProp; + isOffline: boolean; + isLoadingInitialActions: boolean; + skeletonReasonAttributes: SkeletonSpanReasonAttributes; +}; + +function MoneyRequestReportUnifiedList({ + controller, + report, + policy, + visibleReportActions, + renderReportAction, + linkedReportActionID, + listRef, + accessibilityLabel, + onLayout, + onScroll, + onScrollBeginDrag, + onContentSizeChange, + onViewableItemsChanged, + onEndReached, + onStartReached, + contentContainerStyle, + isOffline, + isLoadingInitialActions, + skeletonReasonAttributes, +}: MoneyRequestReportUnifiedListProps) { + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); + const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + const lastTransactionItemIndex = controller.transactionListItems.length - 1; + const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + + // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against + // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, + // so we shift all viewable indices down before forwarding so the comparison is apples-to-apples. + const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { + if (reportActionIndexOffset === 0) { + onViewableItemsChanged(info); + return; + } + onViewableItemsChanged({ + ...info, + viewableItems: info.viewableItems.map((item) => ({...item, index: item.index !== null ? item.index - reportActionIndexOffset : null})), + }); + }; + + const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => { + switch (item.type) { + case 'section-header': + case 'transaction': + return controller.renderTransactionListItem(item, {isFirst: index === 0, isLast: index === lastTransactionItemIndex}); + case 'transactions-footer': + return controller.afterListContent; + case 'report-action': + return renderReportAction(item.action, index - reportActionIndexOffset); + default: + return null; + } + }; + + const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; + const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; + + return ( + + + ref={listRef} + accessibilityLabel={accessibilityLabel} + testID="money-request-report-actions-list" + data={data} + renderItem={dispatchRenderItem} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + initialScrollIndex={initialScrollIndex} + maintainVisibleContentPosition={{ + autoscrollToBottomThreshold: 0, + }} + onViewableItemsChanged={onViewableItemsChangedAdjusted} + onLayout={onLayout} + onEndReached={onEndReached} + onStartReached={onStartReached} + ListHeaderComponent={ + <> + + {controller.beforeListContent} + + } + keyboardShouldPersistTaps="handled" + onScroll={onScroll} + onScrollBeginDrag={onScrollBeginDrag} + onContentSizeChange={onContentSizeChange} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + drawDistance={1000} + /> + + ); +} + +export default memo(MoneyRequestReportUnifiedList); +export type {UnifiedListItem, MoneyRequestReportUnifiedListProps}; From 1884a8d9c70d32d5b1e8e5d8423b9e116874fd94 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 10:11:25 +0200 Subject: [PATCH 17/31] Defer mark-as-read until scroll reaches bottom in Latest messages --- Mobile-Expensify | 2 +- .../MoneyRequestReportActionsList.tsx | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9324cc49f71f..3c67caa84793 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9324cc49f71f8f0786438ad966fbdb0f04ce2941 +Subproject commit 3c67caa847937284500ebf8d7b3777a31092517c diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 034011fa97dc..a27b309f21c4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -194,6 +194,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const readActionSkipped = useRef(false); const stickToBottomRef = useRef(false); const stickToBottomTimeoutRef = useRef(null); + // Set when the user taps "Latest messages"; the report is marked as read only once the scroll actually reaches the bottom. + const pendingMarkAsReadRef = useRef(false); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated; const userActiveSince = useRef(DateUtils.getDBTime()); @@ -438,6 +440,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) // We additionally track the top offset to be able to scroll to the new transaction when it's added scrollingVerticalTopOffset.current = contentOffset.y; + + // Mark the report as read only once the scroll has actually reached the bottom. The jump fired by + // "Latest messages" settles over several frames as deferred items hydrate, so we wait for the real end. + if (pendingMarkAsReadRef.current && scrollingVerticalBottomOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + pendingMarkAsReadRef.current = false; + readActionSkipped.current = false; + readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); + } }, hasOnceLoadedReportActions: !!reportLoadingState?.hasOnceLoadedReportActions, }); @@ -583,7 +593,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) ], ); - const scrollToBottomAndMarkReportAsRead = useCallback(() => { + const scrollToLatestMessages = useCallback(() => { setIsFloatingMessageCounterVisible(false); stickToBottomRef.current = true; @@ -602,10 +612,10 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) return; } + // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). + pendingMarkAsReadRef.current = true; reportScrollManager.scrollToEnd(); - readActionSkipped.current = false; - readNewestAction(reportID, !!reportLoadingState?.hasOnceLoadedReportActions); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, reportLoadingState?.hasOnceLoadedReportActions, introSelected, betas]); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, introSelected, betas]); useEffect(() => { return () => { @@ -625,6 +635,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const onListScrollBeginDrag = () => { stickToBottomRef.current = false; + // The user scrolled away before reaching the bottom, so cancel the pending read. + pendingMarkAsReadRef.current = false; if (stickToBottomTimeoutRef.current) { clearTimeout(stickToBottomTimeoutRef.current); stickToBottomTimeoutRef.current = null; @@ -678,7 +690,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) {/* Exactly one of these two branches is active at a time: 1. showEmptyState — genuinely empty report From 032cb740b34ea39df07e6ff7441b8090d7cc3316 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 12:13:50 +0200 Subject: [PATCH 18/31] Precompute report-action errors once for per-transaction RBR check --- .../MoneyRequestReportTransactionList.tsx | 6 ++- src/libs/ReportUtils.ts | 45 ++++++++++++++----- src/libs/TransactionPreviewUtils.ts | 8 +++- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index dff55a89079b..28bfbf8d856f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -44,6 +44,7 @@ import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils'; import { canAddTransaction, + getActionErrorsByTransaction, getAddExpenseDropdownOptions, getBillableAndTaxTotal, getMoneyRequestSpendBreakdown, @@ -431,10 +432,13 @@ function MoneyRequestReportTransactionList({ } const login = currentUserDetails?.login ?? ''; const accountID = currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID; + // Precompute action errors once (O(actions)) so the per-transaction RBR check below is an O(1) lookup + // instead of re-scanning every report action for every transaction. + const actionErrors = getActionErrorsByTransaction(report?.reportID, reportActionsMap); const ids = new Set(); for (const transaction of transactions) { const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`] ?? []; - if (transactionHasRBR(transaction, violations, login, accountID, report, policy, reportActionsMap)) { + if (transactionHasRBR(transaction, violations, login, accountID, report, policy, reportActionsMap, actionErrors)) { ids.add(transaction.transactionID); } } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cf1522b3f9f4..3460830e508d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11183,21 +11183,43 @@ function getTripIDFromTransactionParentReportID(transactionParentReportID: strin /** * Checks if report contains actions with errors */ +/** + * Precomputes report-action error state in a single pass so that per-transaction RBR checks become O(1) lookups + * instead of re-scanning every report action for every transaction (O(transactions × actions)). + * + * - `hasGlobalActionError`: a non-money-request action (or money-request action without an IOUTransactionID) has + * errors. This flags every transaction, matching the original `.some()` fall-through behavior. + * - `transactionIDsWithActionError`: money-request actions whose `IOUTransactionID` has errors, keyed by transaction. + */ +function getActionErrorsByTransaction( + reportID: string | undefined, + reportActions: OnyxEntry | undefined, +): {hasGlobalActionError: boolean; transactionIDsWithActionError: Set} { + const transactionIDsWithActionError = new Set(); + if (!reportID) { + return {hasGlobalActionError: false, transactionIDsWithActionError}; + } + let hasGlobalActionError = false; + for (const action of Object.values(reportActions ?? {})) { + if (!action || isEmptyValueObject(action.errors)) { + continue; + } + const iouTransactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; + if (iouTransactionID) { + transactionIDsWithActionError.add(iouTransactionID); + } else { + hasGlobalActionError = true; + } + } + return {hasGlobalActionError, transactionIDsWithActionError}; +} + function hasActionWithErrorsForTransaction(reportID: string | undefined, transaction: Transaction | undefined, reportActions: OnyxEntry | undefined): boolean { if (!reportID) { return false; } - return Object.values(reportActions ?? {}) - .filter(Boolean) - .some((action) => { - if (isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID) { - if (getOriginalMessage(action)?.IOUTransactionID === transaction?.transactionID) { - return !isEmptyValueObject(action.errors); - } - return false; - } - return !isEmptyValueObject(action.errors); - }); + const {hasGlobalActionError, transactionIDsWithActionError} = getActionErrorsByTransaction(reportID, reportActions); + return hasGlobalActionError || (!!transaction?.transactionID && transactionIDsWithActionError.has(transaction.transactionID)); } function isNonAdminOrOwnerOfPolicyExpenseChat(report: OnyxInputOrEntry, policy: OnyxInputOrEntry): boolean { @@ -13406,6 +13428,7 @@ export { getHarvestOriginalReportID, getPayeeName, getPolicyIDsWithEmptyReportsForAccount, + getActionErrorsByTransaction, hasActionWithErrorsForTransaction, hasAutomatedExpensifyAccountIDs, hasEmptyReportsForPolicy, diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index 087dee8012a2..e9625082685c 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -474,6 +474,9 @@ function transactionHasRBR( iouReport: OnyxEntry, policy: OnyxEntry, reportActions?: OnyxTypes.ReportActions, + // Optional precomputed action-error state. When provided, the per-transaction action-error check is an O(1) + // lookup instead of re-scanning every report action — build it once with getActionErrorsByTransaction. + actionErrors?: {hasGlobalActionError: boolean; transactionIDsWithActionError: Set}, ): boolean { if (!transaction) { return false; @@ -523,7 +526,10 @@ function transactionHasRBR( } // Check for report action errors associated with this transaction - if (hasActionWithErrorsForTransaction(iouReport?.reportID, transaction, reportActions)) { + const hasActionError = actionErrors + ? (!!iouReport?.reportID && actionErrors.hasGlobalActionError) || (!!transaction?.transactionID && actionErrors.transactionIDsWithActionError.has(transaction.transactionID)) + : hasActionWithErrorsForTransaction(iouReport?.reportID, transaction, reportActions); + if (hasActionError) { return true; } From d681da1595697b616efb4628402bccccc4c80c4b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 12:50:27 +0200 Subject: [PATCH 19/31] Feed stable report projection to transaction list to avoid read-state re-renders --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 4 ++-- .../MoneyRequestReportTransactionList.tsx | 5 +++-- src/selectors/Report.ts | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a27b309f21c4..21290fd1a164 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -708,9 +708,9 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) /> )} - {!isReportEmpty && ( + {!isReportEmpty && !!reportStable && ( Date: Tue, 2 Jun 2026 13:58:57 +0200 Subject: [PATCH 20/31] Pass transaction thread ID so report-view rows skip RBR inner when empty --- .../MoneyRequestReportTransactionItem.tsx | 5 +++++ .../MoneyRequestReportTransactionList.tsx | 19 ++++++++++++++++++- src/components/TransactionItemRow/index.tsx | 4 +++- src/components/TransactionItemRow/types.ts | 3 +++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 8a221df4cea8..af3f9f941ecc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -81,6 +81,9 @@ type MoneyRequestReportTransactionItemProps = { /** Whether the list is horizontally scrollable */ shouldScrollHorizontally?: boolean; + + /** Precomputed transaction-thread report ID, forwarded to the RBR so rows without RBR content can early-return instead of mounting the heavy inner. */ + transactionThreadReportID?: string; }; type MoneyRequestReportTransactionItemBodyProps = MoneyRequestReportTransactionItemProps & { @@ -114,6 +117,7 @@ function MoneyRequestReportTransactionItemBody({ nonPersonalAndWorkspaceCards, isLastItem = false, shouldScrollHorizontally = false, + transactionThreadReportID, inlineEdit, animatedHighlightStyle, shouldSkipDeferRBR = false, @@ -226,6 +230,7 @@ function MoneyRequestReportTransactionItemBody({ onEditAmount={inlineEdit?.onEditAmount} onEditTag={inlineEdit?.onEditTag} shouldSkipDeferRBR={shouldSkipDeferRBR} + transactionThreadReportID={transactionThreadReportID} /> )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 1eb58a0191aa..63fc7943562d 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -40,7 +40,7 @@ import {resolveTransactionCardFields} from '@libs/CardUtils'; import {hasNonReimbursableTransactions, isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {navigationRef} from '@libs/Navigation/Navigation'; import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getIOUActionForTransactionID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils'; import { canAddTransaction, @@ -426,6 +426,22 @@ function MoneyRequestReportTransactionList({ // Convert reportActions array to a record keyed by reportActionID for transactionHasRBR const reportActionsMap = useMemo(() => Object.fromEntries(reportActions.map((ra) => [ra.reportActionID, ra])), [reportActions]); + // Precompute transactionID → transaction-thread report ID in a single pass so each row can pass it to the RBR, + // which lets rows without RBR content early-return instead of mounting the heavy 6-subscription RBR inner. + const transactionThreadReportIDByTransactionID = useMemo(() => { + const map = new Map(); + for (const action of reportActions) { + if (!isMoneyRequestAction(action)) { + continue; + } + const iouTransactionID = getOriginalMessage(action)?.IOUTransactionID; + if (iouTransactionID && action.childReportID) { + map.set(iouTransactionID, action.childReportID); + } + } + return map; + }, [reportActions]); + // Precompute the set of RBR-flagged transaction IDs const rbrTransactionIDs = useMemo(() => { if (!isDefaultSort || !allTransactionViolations) { @@ -816,6 +832,7 @@ function MoneyRequestReportTransactionList({ nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards ?? {}} isLastItem={!showPendingExpensePlaceholder && transaction.transactionID === lastTransactionID} shouldScrollHorizontally={shouldScrollHorizontally} + transactionThreadReportID={transactionThreadReportIDByTransactionID.get(transaction.transactionID)} /> diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx index 2ec0e6fecb55..a4708cbfe656 100644 --- a/src/components/TransactionItemRow/index.tsx +++ b/src/components/TransactionItemRow/index.tsx @@ -74,6 +74,7 @@ function TransactionItemRow({ isHover = false, shouldShowArrowRightOnNarrowLayout, reportActions, + transactionThreadReportID: transactionThreadReportIDProp, checkboxSentryLabel, nonPersonalAndWorkspaceCards = {}, isAttendeesEnabledForMovingPolicy, @@ -97,7 +98,8 @@ function TransactionItemRow({ const styles = useThemeStyles(); const {translate} = useLocalize(); const createdAt = getTransactionCreated(transactionItem); - const transactionThreadReportID = reportActions ? getIOUActionForTransactionID(reportActions, transactionItem.transactionID)?.childReportID : undefined; + const transactionThreadReportID = + transactionThreadReportIDProp ?? (reportActions ? getIOUActionForTransactionID(reportActions, transactionItem.transactionID)?.childReportID : undefined); const transactionAttendees = useAttendees(transactionItem); const bgActiveStyles = isSelected && shouldHighlightItemWhenSelected ? styles.activeComponentBG : EMPTY_ACTIVE_STYLE; diff --git a/src/components/TransactionItemRow/types.ts b/src/components/TransactionItemRow/types.ts index 00524eeaa02e..d3dfff1a0436 100644 --- a/src/components/TransactionItemRow/types.ts +++ b/src/components/TransactionItemRow/types.ts @@ -83,6 +83,9 @@ type TransactionItemRowProps = { isHover?: boolean; shouldShowArrowRightOnNarrowLayout?: boolean; reportActions?: ReportAction[]; + /** Precomputed transaction-thread report ID. When provided, skips the per-row report-actions scan used to derive it + * (lets callers that already know the thread mapping avoid O(transactions × actions) work). */ + transactionThreadReportID?: string; checkboxSentryLabel?: string; isLargeScreenWidth?: boolean; /** Precomputed shouldShowAttendees(SUBMIT, policyForMovingExpenses); drilled instead of the policy object From 52e87f126ccf14130f64b9ca2f2c3c2b9a99023b Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 15:24:23 +0200 Subject: [PATCH 21/31] Jump to latest messages via scrollToIndex to avoid blank landing on large lists --- Mobile-Expensify | 2 +- .../MoneyRequestReportActionsList.tsx | 27 ++++++++++++++++--- .../MoneyRequestReportTransactionList.tsx | 5 ++++ .../MoneyRequestReportUnifiedList.tsx | 14 +++++++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 3c67caa84793..91f3ea56cb9f 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3c67caa847937284500ebf8d7b3777a31092517c +Subproject commit 91f3ea56cb9fc0352983bd721712d556e96d875e diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 21290fd1a164..7018a4451a32 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -87,6 +87,24 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const {translate, getLocalDateFromDatetime} = useLocalize(); const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const reportScrollManager = useReportScrollManager(); + // The unified list writes its last item index here (see lastItemIndexRef prop). We jump to the bottom via + // scrollToIndex rather than scrollToEnd: scrollToEnd targets an estimated content-end offset, which on a large + // list (hundreds of transactions + chat) leaves the bottom blank until it renders/corrects. scrollToIndex + // targets the last item directly and renders around it, so the landing is not blank. + const lastItemIndexRef = useRef(0); + const updateLastItemIndex = useCallback((index: number) => { + lastItemIndexRef.current = index; + }, []); + + const scrollToBottom = useCallback(() => { + if (lastItemIndexRef.current < 0) { + return; + } + + const listRef = reportScrollManager.ref as unknown as React.RefObject>; + listRef.current?.scrollToIndex({index: lastItemIndexRef.current, animated: false}); + }, [reportScrollManager.ref]); + const lastMessageTime = useRef(null); const didLayout = useRef(false); const [isVisible, setIsVisible] = useState(Visibility.isVisible); @@ -608,14 +626,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) if (!hasNewestReportAction) { openReport({reportID, introSelected, betas}); - reportScrollManager.scrollToEnd(); + scrollToBottom(); return; } // Defer marking the report as read until the scroll actually reaches the bottom (handled in onTrackScrolling). pendingMarkAsReadRef.current = true; - reportScrollManager.scrollToEnd(); - }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, reportScrollManager, reportID, introSelected, betas]); + scrollToBottom(); + }, [setIsFloatingMessageCounterVisible, hasNewestReportAction, scrollToBottom, reportID, introSelected, betas]); useEffect(() => { return () => { @@ -630,7 +648,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) if (!stickToBottomRef.current) { return; } - reportScrollManager.scrollToEnd(); + scrollToBottom(); }; const onListScrollBeginDrag = () => { @@ -724,6 +742,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) renderReportAction={renderReportAction} linkedReportActionID={linkedReportActionID} listRef={reportScrollManager.ref as unknown as React.Ref>} + onLastItemIndexChange={updateLastItemIndex} accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} onListLayout={recordTimeToMeasureItemLayout} onScroll={trackVerticalScrolling} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 63fc7943562d..81390f250fe3 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -196,6 +196,9 @@ type MoneyRequestReportTransactionListProps = { /** Ref forwarded to the underlying FlashList. */ listRef: React.Ref>; + /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ + onLastItemIndexChange?: (index: number) => void; + /** Accessibility label for the unified list. */ accessibilityLabel: string; @@ -252,6 +255,7 @@ function MoneyRequestReportTransactionList({ renderReportAction, linkedReportActionID, listRef, + onLastItemIndexChange, accessibilityLabel, onListLayout, onScroll, @@ -1068,6 +1072,7 @@ function MoneyRequestReportTransactionList({ renderReportAction={renderReportAction} linkedReportActionID={linkedReportActionID} listRef={listRef} + onLastItemIndexChange={onLastItemIndexChange} accessibilityLabel={accessibilityLabel} onLayout={onListLayout} onScroll={onScroll} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 95ab9e6583ea..139c118ad98b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,6 +1,6 @@ import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; import {FlashList} from '@shopify/flash-list'; -import React, {memo} from 'react'; +import React, {memo, useEffect} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type * as OnyxTypes from '@src/types/onyx'; @@ -53,6 +53,9 @@ type MoneyRequestReportUnifiedListProps = { isOffline: boolean; isLoadingInitialActions: boolean; skeletonReasonAttributes: SkeletonSpanReasonAttributes; + /** Reports the index of the last list item so callers can jump to the bottom via scrollToIndex (which renders the + * landing region, unlike scrollToEnd's estimated-offset jump that leaves the bottom blank on large lists). */ + onLastItemIndexChange?: (index: number) => void; }; function MoneyRequestReportUnifiedList({ @@ -75,9 +78,18 @@ function MoneyRequestReportUnifiedList({ isOffline, isLoadingInitialActions, skeletonReasonAttributes, + onLastItemIndexChange, }: MoneyRequestReportUnifiedListProps) { const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + + // Report the last index so callers can jump to the bottom via scrollToIndex. + const lastDataIndex = data.length - 1; + + useEffect(() => { + onLastItemIndexChange?.(lastDataIndex); + }, [lastDataIndex, onLastItemIndexChange]); + const lastTransactionItemIndex = controller.transactionListItems.length - 1; const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; From 42396131b9c8202aab1b064d1aa87300b3303df9 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 15:32:37 +0200 Subject: [PATCH 22/31] remove timeout --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 7018a4451a32..ad85e67f1f72 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -618,11 +618,6 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) if (stickToBottomTimeoutRef.current) { clearTimeout(stickToBottomTimeoutRef.current); } - // Safety net: stop pinning after deferred content has had time to settle, so a much later - // unrelated layout change doesn't yank the user back down. - stickToBottomTimeoutRef.current = setTimeout(() => { - stickToBottomRef.current = false; - }, 2000); if (!hasNewestReportAction) { openReport({reportID, introSelected, betas}); From a92c4daad16b4714b4731f87ca89f538c3696a0c Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 2 Jun 2026 15:36:40 +0200 Subject: [PATCH 23/31] Revert "remove timeout" This reverts commit 42396131b9c8202aab1b064d1aa87300b3303df9. --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index ad85e67f1f72..7018a4451a32 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -618,6 +618,11 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) if (stickToBottomTimeoutRef.current) { clearTimeout(stickToBottomTimeoutRef.current); } + // Safety net: stop pinning after deferred content has had time to settle, so a much later + // unrelated layout change doesn't yank the user back down. + stickToBottomTimeoutRef.current = setTimeout(() => { + stickToBottomRef.current = false; + }, 2000); if (!hasNewestReportAction) { openReport({reportID, introSelected, betas}); From 5546ddf01e2b0de5240aed0fd4dd55698aeb574e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 3 Jun 2026 13:48:26 +0200 Subject: [PATCH 24/31] remove dead code (knip) --- .../BaseFlatListWithScrollKey.tsx | 90 ------------------- .../FlatListWithScrollKey/index.ios.tsx | 62 ------------- .../FlatList/FlatListWithScrollKey/index.tsx | 17 ---- .../FlatList/FlatListWithScrollKey/types.ts | 16 ---- .../MoneyRequestReportUnifiedList.tsx | 2 +- .../index.native.ts | 4 - .../index.ts | 7 -- 7 files changed, 1 insertion(+), 197 deletions(-) delete mode 100644 src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx delete mode 100644 src/components/FlatList/FlatListWithScrollKey/index.ios.tsx delete mode 100644 src/components/FlatList/FlatListWithScrollKey/index.tsx delete mode 100644 src/components/FlatList/FlatListWithScrollKey/types.ts delete mode 100644 src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts delete mode 100644 src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts diff --git a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx b/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx deleted file mode 100644 index 110f04fd3c35..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/BaseFlatListWithScrollKey.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import FlatList from '@components/FlatList/FlatList'; -import useFlatListScrollKey from '@components/FlatList/hooks/useFlatListScrollKey'; -import type {BaseFlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function BaseFlatListWithScrollKey({ref, ...props}: BaseFlatListWithScrollKeyProps) { - const { - shouldEnableAutoScrollToTopThreshold, - initialScrollKey, - data, - onStartReached, - renderItem, - keyExtractor, - onViewableItemsChanged, - onContentSizeChange, - onScrollBeginDrag, - onWheel, - onTouchStartCapture, - ...restProps - } = props; - const {displayedData, maintainVisibleContentPosition, handleStartReached, isInitialData, handleRenderItem, listRef} = useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - inverted: false, - onStartReached, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - }); - - const isLoadingData = useRef(true); - const isInitialDataRef = useRef(isInitialData); - // Determine whether the user has interacted with the FlatList, - // ensuring that handleStartReached is only triggered within onViewableItemsChanged after user interaction. - const hasUserInteractedRef = useRef(false); - - useEffect(() => { - isInitialDataRef.current = isInitialData; - - if (!isLoadingData.current || data.length > displayedData.length) { - return; - } - - isLoadingData.current = false; - }, [data.length, displayedData.length, isInitialData]); - - return ( - onContentSizeChange?.(width, height, isInitialData)} - onViewableItemsChanged={(info) => { - onViewableItemsChanged?.(info); - - if (!hasUserInteractedRef.current || isInitialDataRef.current || !isLoadingData.current || info.viewableItems.length <= 0 || info.viewableItems.at(0)?.index !== 0) { - return; - } - handleStartReached({distanceFromStart: 0}); - }} - onScrollBeginDrag={(e) => { - onScrollBeginDrag?.(e); - hasUserInteractedRef.current = true; - }} - onWheel={(e) => { - onWheel?.(e); - hasUserInteractedRef.current = true; - }} - onTouchStartCapture={(e) => { - onTouchStartCapture?.(e); - hasUserInteractedRef.current = true; - }} - /> - ); -} - -export default BaseFlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx b/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx deleted file mode 100644 index e912917994b8..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.ios.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, {useCallback, useRef} from 'react'; -import type {LayoutChangeEvent, FlatList as RNFlatList} from 'react-native'; -import mergeRefs from '@libs/mergeRefs'; -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; -import type {FlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - const {initialScrollKey, onLayout, onContentSizeChange, ...rest} = props; - - const flatListHeight = useRef(0); - const shouldScrollToEndRef = useRef(false); - const listRef = useRef(null); - - const onLayoutInner = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - - flatListHeight.current = event.nativeEvent.layout.height; - }, - [onLayout], - ); - - const onContentSizeChangeInner = useCallback( - (w: number, h: number, isInitialData?: boolean) => { - onContentSizeChange?.(w, h); - - if (!initialScrollKey) { - return; - } - // Since the ListHeaderComponent is only rendered after the data has finished rendering, iOS locks the entire current viewport. - // As a result, the viewport does not automatically scroll down to fill the gap at the bottom. - // We will check during the initial render (isInitialData === true). If the content height is less than the layout height, - // it means there is a gap at the bottom. - // Then, once the render is complete (isInitialData === false), we will manually scroll to the bottom. - if (shouldScrollToEndRef.current) { - requestAnimationFrame(() => { - listRef.current?.scrollToEnd(); - }); - shouldScrollToEndRef.current = false; - } - if (h < flatListHeight.current && isInitialData) { - shouldScrollToEndRef.current = true; - } - }, - [onContentSizeChange, initialScrollKey], - ); - - return ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/index.tsx b/src/components/FlatList/FlatListWithScrollKey/index.tsx deleted file mode 100644 index e487843c3897..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import BaseFlatListWithScrollKey from './BaseFlatListWithScrollKey'; -import type {FlatListWithScrollKeyProps} from './types'; - -/** - * FlatList component that handles initial scroll key. - */ -function FlatListWithScrollKey({ref, ...props}: FlatListWithScrollKeyProps) { - return ( - - ); -} - -export default FlatListWithScrollKey; diff --git a/src/components/FlatList/FlatListWithScrollKey/types.ts b/src/components/FlatList/FlatListWithScrollKey/types.ts deleted file mode 100644 index 5a8145be74c3..000000000000 --- a/src/components/FlatList/FlatListWithScrollKey/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type {ForwardedRef} from 'react'; -import type {FlatListProps, ListRenderItem, FlatList as RNFlatList} from 'react-native'; - -type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { - data: T[]; - initialScrollKey?: string | null | undefined; - keyExtractor: (item: T, index: number) => string; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - onContentSizeChange?: (contentWidth: number, contentHeight: number, isInitialData?: boolean) => void; - ref: ForwardedRef; -}; - -type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; - -export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 139c118ad98b..5890f2aa53c9 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -168,4 +168,4 @@ function MoneyRequestReportUnifiedList({ } export default memo(MoneyRequestReportUnifiedList); -export type {UnifiedListItem, MoneyRequestReportUnifiedListProps}; +export type {UnifiedListItem}; diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts deleted file mode 100644 index 4d0986216e59..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.native.ts +++ /dev/null @@ -1,4 +0,0 @@ -function getInitialNumToRender(numToRender: number): number { - return numToRender; -} -export default getInitialNumToRender; diff --git a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts b/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts deleted file mode 100644 index 217d5d3789dd..000000000000 --- a/src/pages/inbox/report/getInitialNumReportActionsToRender/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -const DEFAULT_NUM_TO_RENDER = 50; - -function getInitialNumToRender(numToRender: number): number { - // For web environment, it's crucial to set this value equal to or higher than the maxToRenderPerBatch setting. If it's set lower, the 'onStartReached' event will be triggered excessively, every time an additional item enters the virtualized list. - return Math.max(numToRender, DEFAULT_NUM_TO_RENDER); -} -export default getInitialNumToRender; From 961619b8f66074a1f98cb340cfa22d69b94b8d7c Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 3 Jun 2026 13:48:33 +0200 Subject: [PATCH 25/31] fix spellcheck --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 0fd5b7b48d32..4c913839ad8b 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11190,7 +11190,7 @@ function getTripIDFromTransactionParentReportID(transactionParentReportID: strin * Checks if report contains actions with errors */ /** - * Precomputes report-action error state in a single pass so that per-transaction RBR checks become O(1) lookups + * Computes report-action error state in a single pass so that per-transaction RBR checks become O(1) lookups * instead of re-scanning every report action for every transaction (O(transactions × actions)). * * - `hasGlobalActionError`: a non-money-request action (or money-request action without an IOUTransactionID) has From 9845546caafff36118b47792214f2b8b493042db Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 3 Jun 2026 14:38:13 +0200 Subject: [PATCH 26/31] fix lint --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 9613276e539a..ae71b932f118 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -5,7 +5,7 @@ import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; // eslint-disable-next-line no-restricted-imports -import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; +import {DeviceEventEmitter, View} from 'react-native'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLoadReportActions from '@hooks/useLoadReportActions'; From 65843301cba1467f15e40db5a23892f29ad2fecc Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 3 Jun 2026 14:44:46 +0200 Subject: [PATCH 27/31] fix knip --- src/CONST/index.ts | 1 + .../FlatList/hooks/useFlatListScrollKey.ts | 183 ------------------ .../MoneyRequestReportActionsList.tsx | 1 - .../useScrollToEndOnNewMessageReceived.ts | 4 +- src/pages/inbox/report/ReportActionsList.tsx | 3 +- 5 files changed, 4 insertions(+), 188 deletions(-) delete mode 100644 src/components/FlatList/hooks/useFlatListScrollKey.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c3a1367fa59c..cbf2cf58961d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1754,6 +1754,7 @@ const CONST = { THREAD_DISABLED: ['CREATED'], LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD: 2000, ACTION_VISIBLE_THRESHOLD: 250, + AUTOSCROLL_TO_TOP_THRESHOLD: 250, MAX_GROUPING_TIME: 300000, }, CANCEL_PAYMENT_REASONS: { diff --git a/src/components/FlatList/hooks/useFlatListScrollKey.ts b/src/components/FlatList/hooks/useFlatListScrollKey.ts deleted file mode 100644 index fac0b5cf2f11..000000000000 --- a/src/components/FlatList/hooks/useFlatListScrollKey.ts +++ /dev/null @@ -1,183 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {ForwardedRef} from 'react'; -import type {ListRenderItem, ListRenderItemInfo, FlatList as RNFlatList} from 'react-native'; -import {View} from 'react-native'; -import getInitialPaginationSize from '@components/FlatList/getInitialPaginationSize'; -import RenderTaskQueue from '@components/FlatList/RenderTaskQueue'; -import type {FlatListInnerRefType} from '@components/FlatList/types'; -import type {ScrollViewProps} from '@components/ScrollView'; -import usePrevious from '@hooks/usePrevious'; -import getPlatform from '@libs/getPlatform'; -import CONST from '@src/CONST'; -import useFlatListHandle from './useFlatListHandle'; - -type FlatListScrollKeyProps = { - ref?: ForwardedRef>; - data: T[]; - keyExtractor: (item: T, index: number) => string; - initialScrollKey: string | null | undefined; - inverted: boolean; - onStartReached?: ((info: {distanceFromStart: number}) => void) | null; - shouldEnableAutoScrollToTopThreshold?: boolean; - renderItem: ListRenderItem; - remainingItemsToDisplay?: number; - onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void; -}; - -const AUTOSCROLL_TO_TOP_THRESHOLD = 250; - -export default function useFlatListScrollKey({ - data, - keyExtractor, - initialScrollKey, - onStartReached, - inverted, - shouldEnableAutoScrollToTopThreshold, - renderItem, - ref, - remainingItemsToDisplay, - onScrollToIndexFailed, -}: FlatListScrollKeyProps) { - // `initialScrollIndex` doesn't work properly with FlatList, this uses an alternative approach to achieve the same effect. - // What we do is start rendering the list from `initialScrollKey` and then whenever we reach the start we render more - // previous items, until everything is rendered. We also progressively render new data that is added at the start of the - // list to make sure `maintainVisibleContentPosition` works as expected. - const [currentDataId, setCurrentDataId] = useState(() => { - if (initialScrollKey) { - return initialScrollKey; - } - return null; - }); - const currentDataIndex = useMemo(() => (currentDataId === null ? -1 : data.findIndex((item, index) => keyExtractor(item, index) === currentDataId)), [currentDataId, data, keyExtractor]); - const [isInitialData, setIsInitialData] = useState(currentDataIndex >= 0); - const [isQueueRendering, setIsQueueRendering] = useState(false); - - // On the web platform, when data.length === 1, `maintainVisibleContentPosition` does not work. - // Therefore, we need to duplicate the data to ensure data.length >= 2 - const shouldDuplicateData = useMemo(() => !inverted && data.length === 1 && isInitialData && getPlatform() === CONST.PLATFORM.WEB, [data.length, inverted, isInitialData]); - - const displayedData = useMemo(() => { - if (shouldDuplicateData) { - return [{...data.at(0), reportActionID: '0'} as T, ...data]; - } - if (currentDataIndex <= 0) { - return data; - } - // If data.length > 1 and highlighted item is the last element, there will be a bug that does not trigger the `onStartReached` event. - // So we will need to return at least the last 2 elements in this case. - const offset = !inverted && currentDataIndex === data.length - 1 ? 1 : 0; - // We always render the list from the highlighted item to the end of the list because: - // - With an inverted FlatList, items are rendered from bottom to top, - // so the highlighted item stays at the bottom and within the visible viewport. - // - With a non-inverted (base) FlatList, items are rendered from top to bottom, - // making the highlighted item appear at the top of the list. - // Then, `maintainVisibleContentPosition` ensures the highlighted item remains in place - // as the rest of the items are appended. - return data.slice(Math.max(0, currentDataIndex - (isInitialData ? offset : getInitialPaginationSize))); - }, [currentDataIndex, data, inverted, isInitialData, shouldDuplicateData]); - - const isLoadingData = data.length > displayedData.length; - const wasLoadingData = usePrevious(isLoadingData); - const dataIndexDifference = data.length - displayedData.length; - - // Queue up updates to the displayed data to avoid adding too many at once and cause jumps in the list. - const renderQueue = useMemo(() => new RenderTaskQueue(setIsQueueRendering), []); - useEffect(() => { - return () => { - renderQueue.cancel(); - }; - }, [renderQueue]); - - renderQueue.setHandler((info) => { - if (!isLoadingData) { - onStartReached?.(info); - } - setIsInitialData(false); - const firstDisplayedItem = displayedData.at(0); - setCurrentDataId(firstDisplayedItem ? keyExtractor(firstDisplayedItem, Math.max(0, currentDataIndex)) : null); - }); - - const handleStartReached = useCallback( - (info: {distanceFromStart: number}) => { - renderQueue.add(info); - }, - [renderQueue], - ); - - useEffect(() => { - // In cases where the data is empty on the initial render, `handleStartReached` will never be triggered. - // We'll manually invoke it in this scenario. - if (inverted || data.length > 0) { - return; - } - handleStartReached({distanceFromStart: 0}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const [shouldPreserveVisibleContentPosition, setShouldPreserveVisibleContentPosition] = useState(true); - const maintainVisibleContentPosition = useMemo(() => { - if ((!initialScrollKey && (!isInitialData || !isQueueRendering)) || !shouldPreserveVisibleContentPosition) { - return undefined; - } - - const config: ScrollViewProps['maintainVisibleContentPosition'] = { - // This needs to be 1 to avoid using loading views as anchors. - minIndexForVisible: data.length ? Math.min(1, data.length - 1) : 0, - }; - - if (shouldEnableAutoScrollToTopThreshold && !isLoadingData && !wasLoadingData) { - config.autoscrollToTopThreshold = AUTOSCROLL_TO_TOP_THRESHOLD; - } - - return config; - }, [initialScrollKey, isInitialData, isQueueRendering, shouldPreserveVisibleContentPosition, data.length, shouldEnableAutoScrollToTopThreshold, isLoadingData, wasLoadingData]); - - const handleRenderItem = useCallback( - ({item, index, separators}: ListRenderItemInfo) => { - // Adjust the index passed here so it matches the original data. - if (shouldDuplicateData && index === 1) { - return React.createElement(View, {style: {opacity: 0}}, renderItem({item, index: index + dataIndexDifference, separators})); - } - - return renderItem({item, index: index + dataIndexDifference, separators}); - }, - [shouldDuplicateData, renderItem, dataIndexDifference], - ); - - useEffect(() => { - if (inverted || isInitialData || isQueueRendering) { - return; - } - - // Unlike an inverted FlatList, a non-inverted FlatList can have data.length === 0, - // which causes the initial value of `minIndexForVisible` to be 0. - // When data.length increases and `minIndexForVisible` updates accordingly, - // it can lead to a crash due to inconsistent rendering behavior. - // Additionally, keeping `minIndexForVisible` at 1 may cause the scroll offset to shift - // when the height of the ListHeaderComponent changes, as FlatList tries to keep items within the visible viewport. - requestAnimationFrame(() => { - setShouldPreserveVisibleContentPosition(false); - }); - }, [inverted, isInitialData, isQueueRendering]); - - const listRef = useRef | null>(null); - useFlatListHandle({ - ref, - listRef, - remainingItemsToDisplay, - setCurrentDataId, - onScrollToIndexFailed, - }); - - return { - handleStartReached, - setCurrentDataId, - displayedData, - maintainVisibleContentPosition, - isInitialData, - handleRenderItem, - listRef, - }; -} - -export {AUTOSCROLL_TO_TOP_THRESHOLD}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index ae71b932f118..a3a5e8caa3ce 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -4,7 +4,6 @@ import type {FlashListRef} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -// eslint-disable-next-line no-restricted-imports import {DeviceEventEmitter, View} from 'react-native'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; diff --git a/src/hooks/useScrollToEndOnNewMessageReceived.ts b/src/hooks/useScrollToEndOnNewMessageReceived.ts index 688db5c8f78f..ba3dd2cfa224 100644 --- a/src/hooks/useScrollToEndOnNewMessageReceived.ts +++ b/src/hooks/useScrollToEndOnNewMessageReceived.ts @@ -1,6 +1,6 @@ import {useEffect, useLayoutEffect, useRef} from 'react'; import type React from 'react'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; +import CONST from '@src/CONST'; import usePrevious from './usePrevious'; type UseScrollToEndOnPaginationMergeParams = { @@ -63,7 +63,7 @@ function useScrollToEndOnNewMessageReceived({ const didListSizeChange = sizeChangeType === 'grewFromReportActions' ? reportActionSize.current > (reportActionsLength ?? 0) : reportActionSize.current !== visibleActionsLength; if ( - scrollOffsetRef.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollOffsetRef.current < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionID && didListSizeChange && hasNewestReportAction && diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index f15396d0c4a4..7de48fa0e919 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -7,7 +7,6 @@ import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; -import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/FlatList/hooks/useFlatListScrollKey'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; @@ -519,7 +518,7 @@ function ReportActionsList({ return; } - if (scrollOffsetRef.current >= AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { return; } From b6382a138a4ae74f5eed334fe47b862e5b97e66e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 3 Jun 2026 14:50:03 +0200 Subject: [PATCH 28/31] fix knip --- .../FlatList/getInitialPaginationSize/index.native.ts | 3 --- src/components/FlatList/getInitialPaginationSize/index.ts | 3 --- 2 files changed, 6 deletions(-) delete mode 100644 src/components/FlatList/getInitialPaginationSize/index.native.ts delete mode 100644 src/components/FlatList/getInitialPaginationSize/index.ts diff --git a/src/components/FlatList/getInitialPaginationSize/index.native.ts b/src/components/FlatList/getInitialPaginationSize/index.native.ts deleted file mode 100644 index 195448f7e450..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.MOBILE_PAGINATION_SIZE; diff --git a/src/components/FlatList/getInitialPaginationSize/index.ts b/src/components/FlatList/getInitialPaginationSize/index.ts deleted file mode 100644 index 87ec6856aa20..000000000000 --- a/src/components/FlatList/getInitialPaginationSize/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CONST from '@src/CONST'; - -export default CONST.WEB_PAGINATION_SIZE; From c2f98c0589e3ba17b064a6bb08377b557e9a9e0e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Wed, 10 Jun 2026 15:29:32 +0200 Subject: [PATCH 29/31] Forward report list ref via @components/FlashList instead of unsafe casts The unified list ref was bridged from the shared ActionList context's FlatList-typed slot to the FlashList via `as unknown as` casts, tripping the no-unsafe-type-assertion seatbelt (3 > 1 allowed). Mirror the InvertedFlashList pattern: type the ref prop as FlatListRefType and forward it through @components/FlashList as a spread prop, so no cast is needed at the call site. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MoneyRequestReportActionsList.tsx | 8 ++--- .../MoneyRequestReportTransactionList.tsx | 5 ++-- .../MoneyRequestReportUnifiedList.tsx | 29 ++++++++++++++++--- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index eb4d6fdcfbbd..ee3df9a2670c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -1,6 +1,5 @@ /* eslint-disable rulesdir/prefer-early-return */ import {useIsFocused, useRoute} from '@react-navigation/native'; -import type {FlashListRef} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; @@ -61,7 +60,6 @@ import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList'; -import type {UnifiedListItem} from './MoneyRequestReportUnifiedList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; import SelectionToolbar from './SelectionToolbar'; @@ -102,8 +100,8 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) return; } - const listRef = reportScrollManager.ref as unknown as React.RefObject>; - listRef.current?.scrollToIndex({index: lastItemIndexRef.current, animated: false}); + const listRef = reportScrollManager.ref; + listRef?.current?.scrollToIndex({index: lastItemIndexRef.current, animated: false}); }, [reportScrollManager.ref]); const lastMessageTime = useRef(null); @@ -746,7 +744,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) visibleReportActions={visibleReportActions} renderReportAction={renderReportAction} linkedReportActionID={linkedReportActionID} - listRef={reportScrollManager.ref as unknown as React.Ref>} + listRef={reportScrollManager.ref} onLastItemIndexChange={updateLastItemIndex} accessibilityLabel={translate('sidebarScreen.listOfChatMessages')} onListLayout={recordTimeToMeasureItemLayout} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index fd7ffef3b883..1f8dc0743515 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -1,6 +1,5 @@ import {findFocusedRoute, useFocusEffect} from '@react-navigation/native'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import type {FlashListRef} from '@shopify/flash-list'; import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -65,6 +64,7 @@ import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransact import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP'; import Navigation from '@navigation/Navigation'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import variables from '@styles/variables'; import {createTransactionThreadReport} from '@userActions/Report'; import CONST from '@src/CONST'; @@ -83,7 +83,6 @@ import MoneyRequestReportTransactionItem from './MoneyRequestReportTransactionIt import MoneyRequestReportTransactionLongPressModal from './MoneyRequestReportTransactionLongPressModal'; import type {MoneyRequestReportTransactionLongPressModalHandle} from './MoneyRequestReportTransactionLongPressModal'; import MoneyRequestReportUnifiedList from './MoneyRequestReportUnifiedList'; -import type {UnifiedListItem} from './MoneyRequestReportUnifiedList'; import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState'; const PENDING_EXPENSE_REASON_ATTRIBUTES = {context: 'MoneyRequestReportTransactionList.PendingExpensePlaceholder'} as const; @@ -194,7 +193,7 @@ type MoneyRequestReportTransactionListProps = { linkedReportActionID: string | undefined; /** Ref forwarded to the underlying FlashList. */ - listRef: React.Ref>; + listRef: FlatListRefType; /** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */ onLastItemIndexChange?: (index: number) => void; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 5890f2aa53c9..1a36ed82d9fc 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,8 +1,9 @@ -import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import {FlashList} from '@shopify/flash-list'; +import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; import React, {memo, useEffect} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import FlashList from '@components/FlashList'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import type {FlatListRefType} from '@pages/inbox/ReportScreenContext'; import type * as OnyxTypes from '@src/types/onyx'; import MoneyRequestReportHorizontalScrollWrapper from './MoneyRequestReportHorizontalScrollWrapper'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; @@ -33,6 +34,26 @@ function unifiedListItemType(item: UnifiedListItem) { return item.type; } +type MoneyRequestReportFlashListProps = FlashListProps & { + /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */ + ref: FlatListRefType; +}; + +/** + * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based + * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the + * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is + * needed. The scroll manager relies on the FlashList registering into this slot. + */ +function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { + return ( + + // eslint-disable-next-line react/jsx-props-no-spreading -- thin forwarder; spreading the props (including the ref) is the point + {...props} + /> + ); +} + type MoneyRequestReportUnifiedListProps = { controller: MoneyRequestReportTransactionListController; report: OnyxTypes.Report; @@ -40,7 +61,7 @@ type MoneyRequestReportUnifiedListProps = { visibleReportActions: OnyxTypes.ReportAction[]; renderReportAction: (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => React.ReactElement; linkedReportActionID: string | undefined; - listRef: React.Ref>; + listRef: FlatListRefType; accessibilityLabel: string; onLayout: () => void; onScroll: (event: NativeSyntheticEvent) => void; @@ -130,7 +151,7 @@ function MoneyRequestReportUnifiedList({ contentWidth={controller.tableMinWidth} restorationKey={controller.horizontalScrollRestorationKey} > - + Date: Wed, 10 Jun 2026 15:39:33 +0200 Subject: [PATCH 30/31] Drop now-unused UnifiedListItem export The type's only external consumers were the ref-cast imports in MoneyRequestReportActionsList and MoneyRequestReportTransactionList, both removed in the previous commit. It is still used internally, so keep the type and drop only the export. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 1a36ed82d9fc..45ab2d2d1c79 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -189,4 +189,3 @@ function MoneyRequestReportUnifiedList({ } export default memo(MoneyRequestReportUnifiedList); -export type {UnifiedListItem}; From 22ca09a01397c2885d17bbdfd5c237cff21e6ac4 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 15 Jun 2026 15:11:15 +0200 Subject: [PATCH 31/31] Remove unused eslint-disable directive for jsx-props-no-spreading Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 45ab2d2d1c79..0b199e3839b0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -48,7 +48,7 @@ type MoneyRequestReportFlashListProps = FlashListProps & { function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) { return ( - // eslint-disable-next-line react/jsx-props-no-spreading -- thin forwarder; spreading the props (including the ref) is the point + // thin forwarder; spreading the props (including the ref) is the point {...props} /> );