From 455162149eefba583d09e30435dfc466c51f7c3a Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 21 May 2026 17:01:53 +0000 Subject: [PATCH 01/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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} /> ); From c83239890f37f7a3a0dc5216f1975b7144e23896 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 2 Jul 2026 10:30:38 +0200 Subject: [PATCH 32/48] Add ExternalScrollFlashListTable + overrideWindowSize flash-list patch --- ...-list+2.3.0+013+external-window-size.patch | 104 +++++++++ patches/@shopify/flash-list/details.md | 8 + .../ExternalScrollFlashListTable.tsx | 200 ++++++++++++++++++ .../MoneyRequestReportUnifiedList.tsx | 138 ++++++++---- 4 files changed, 410 insertions(+), 40 deletions(-) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+external-window-size.patch create mode 100644 src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+external-window-size.patch new file mode 100644 index 000000000000..971a05fa078b --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+external-window-size.patch @@ -0,0 +1,104 @@ +diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +index fa786bf..014eb62 100644 +--- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts ++++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts +@@ -99,6 +99,15 @@ export interface FlashListProps extends Omit | React.ExoticComponent | React.FC; ++ /** ++ * When set, the list uses this as its visible window size instead of measuring its outer container. ++ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll ++ * events), where the outer container is as tall as the full content and can't be used as the viewport. ++ */ ++ overrideWindowSize?: { ++ width: number; ++ height: number; ++ }; + /** + * Draw distance for advanced rendering (in dp/px) + */ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index ef4daf2..063fd1a 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -28,7 +28,7 @@ import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; + const RecyclerViewComponent = (props, ref) => { + var _a, _b, _c, _d; + // Destructure props and initialize refs +- const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props; ++ const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props; + const [renderTimeTracker] = useState(() => new RenderTimeTracker()); + renderTimeTracker.startTracking(); + // Sticky header config +@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => { + : horizontal + ? firstChildViewLayout.x + : firstChildViewLayout.y; ++ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full ++ // content) declare its visible window; everything else still uses the real measurement. ++ const windowSize = overrideWindowSize !== null && overrideWindowSize !== void 0 ? overrideWindowSize : outerViewSize; + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams({ +- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, ++ width: horizontal ? windowSize.width : firstChildViewLayout.width, + height: horizontal + ? firstChildViewLayout.height +- : outerViewSize.height, ++ : windowSize.height, + }, isHorizontalRTL && recyclerViewManager.hasLayout() + ? firstItemOffset - + recyclerViewManager.getChildContainerDimensions().width +diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts +index 76dd0c8..5a5c0f5 100644 +--- a/node_modules/@shopify/flash-list/src/FlashListProps.ts ++++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts +@@ -160,6 +160,16 @@ export interface FlashListProps + | React.ExoticComponent + | React.FC; + ++ /** ++ * When set, the list uses this as its visible window size instead of measuring its outer container. ++ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll ++ * events), where the outer container is as tall as the full content and can't be used as the viewport. ++ */ ++ overrideWindowSize?: { ++ width: number; ++ height: number; ++ }; ++ + /** + * Draw distance for advanced rendering (in dp/px) + */ +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index bc27739..a7829d5 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -90,6 +90,7 @@ const RecyclerViewComponent = ( + onChangeStickyIndex, + stickyHeaderConfig, + inverted, ++ overrideWindowSize, + ...rest + } = props; + +@@ -265,13 +266,17 @@ const RecyclerViewComponent = ( + ? firstChildViewLayout.x + : firstChildViewLayout.y; + ++ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full ++ // content) declare its visible window; everything else still uses the real measurement. ++ const windowSize = overrideWindowSize ?? outerViewSize; ++ + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams( + { +- width: horizontal ? outerViewSize.width : firstChildViewLayout.width, ++ width: horizontal ? windowSize.width : firstChildViewLayout.width, + height: horizontal + ? firstChildViewLayout.height +- : outerViewSize.height, ++ : windowSize.height, + }, + isHorizontalRTL && recyclerViewManager.hasLayout() + ? firstItemOffset - diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index a826ad3f90df..f2d3dde37faf 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -141,3 +141,11 @@ - Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334 - E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263 - PR introducing patch: https://github.com/Expensify/App/pull/92520 + +### [@shopify+flash-list+2.3.0+013+external-window-size.patch](@shopify+flash-list+2.3.0+013+external-window-size.patch) + +- Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll. +- Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx new file mode 100644 index 000000000000..e7052384e43c --- /dev/null +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -0,0 +1,200 @@ +import type {ListRenderItemInfo} from '@shopify/flash-list'; +import React, {useEffect, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; +import FlashList from '@components/FlashList'; +import ScrollView from '@components/ScrollView'; + +/** + * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll + * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches + * the driver, which turns it into a synthetic scroll event that updates only the nested FlashList's render stack. + */ +type ScrollOffsetStore = { + getOffset: () => number; + setOffset: (offset: number) => void; + subscribe: (listener: () => void) => () => void; +}; + +function createScrollOffsetStore(): ScrollOffsetStore { + let offset = 0; + const listeners = new Set<() => void>(); + return { + getOffset: () => offset, + setOffset: (next: number) => { + if (next === offset) { + return; + } + offset = next; + listeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + +// The subset of the ScrollView imperative surface FlashList actually drives — `getScrollableNode` is read internally +// by RecyclerView for bound detection, and `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` back +// FlashList's public ref. Naming it makes "the part of ScrollView the driver must honor" explicit rather than erased. +type MinimalScrollRef = { + scrollTo: () => void; + scrollToEnd: () => void; + flashScrollIndicators: () => void; + getScrollableNode: () => View | null; + getNativeScrollRef: () => View | null; +}; + +// `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed +// call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to +// pass it as `renderScrollComponent`. +type ExternalScrollDriverProps = ScrollViewProps & { + /** Source of the parent list's vertical scroll offset. */ + store?: ScrollOffsetStore; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop?: number; +}; + +/** + * Replacement scroll container for the nested table FlashList. It does NOT scroll — it is a plain View that grows to + * the full content height so the parent page scrolls through it — and it synthesizes FlashList's vertical scroll + * events from the parent's offset. FlashList reads `getScrollableNode` internally and delegates its public + * `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` to this ref; the scroll no-ops mean offset + * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: + * FlashList memoizes its scroll component on identity. + */ +const ExternalScrollDriver = React.forwardRef(function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style}, ref) { + const nodeRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + scrollTo: () => {}, + scrollToEnd: () => {}, + flashScrollIndicators: () => {}, + getScrollableNode: () => nodeRef.current, + getNativeScrollRef: () => nodeRef.current, + }), + [], + ); + + useEffect(() => { + if (!store) { + return; + } + const emit = () => { + // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset — + // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is + // populated; windowing comes from the `overrideWindowSize` prop, not this event. + const y = Math.max(0, store.getOffset() - offsetTop); + onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); + }; + // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the + // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame. + emit(); + return store.subscribe(emit); + }, [store, offsetTop, onScroll]); + + return ( + + {children} + + ); +}); + +type ExternalScrollFlashListTableProps = { + /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */ + items: T[]; + + /** Stable key per row. */ + keyExtractor: (item: T, index: number) => string; + + /** Recycling bucket per row (transaction vs. group header) so FlashList reuses like with like. */ + getItemType: (item: T) => string; + + /** Renders a single row. */ + renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; + + /** Column header rendered above the rows and scrolled horizontally with them. */ + renderHeader: () => React.ReactElement; + + /** Estimated row height used before a row has been measured. */ + estimatedRowHeight: number; + + /** Full table width (wider than the viewport). Drives the horizontal scroll range. */ + contentWidth: number; + + /** Shared offset store fed by the parent's onScroll. */ + store: ScrollOffsetStore; + + /** Visible height of the parent viewport. */ + viewportHeight: number; + + /** Where the table region starts within the parent page's scrollable content (px from the top). */ + offsetTop: number; +}; + +/** + * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled windower. + * + * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full + * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll + * offset — via the patched `overrideWindowSize` prop, which lets FlashList treat the parent viewport as its window + * instead of measuring its own (full-height) container. A single native horizontal ScrollView wraps the whole list, so + * all rows share one smooth horizontal scroll and nothing outside it moves sideways. + * + * LAYOUT NOTE: FlashList's outer container defaults to `flex: 1` + `overflow: hidden` (a clipping viewport). We + * override it via `style` below to grow to content height and not clip, since the page — not the list — owns vertical + * scroll. + */ +function ExternalScrollFlashListTable({ + items, + keyExtractor, + getItemType, + renderItem, + renderHeader, + estimatedRowHeight, + contentWidth, + store, + viewportHeight, + offsetTop, +}: ExternalScrollFlashListTableProps) { + const lastIndex = items.length - 1; + + return ( + + + data={items} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={({item, index}: ListRenderItemInfo) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})} + ListHeaderComponent={renderHeader()} + drawDistance={estimatedRowHeight * 12} + renderScrollComponent={ExternalScrollDriver} + // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component). + overrideProps={{store, offsetTop}} + // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View. + overrideWindowSize={{width: contentWidth, height: viewportHeight}} + // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own + // clipping viewport must be neutralized. + style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}} + scrollEnabled={false} + /> + + ); +} + +export default ExternalScrollFlashListTable; +export {createScrollOffsetStore}; +export type {ScrollOffsetStore}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 0b199e3839b0..50838f7f8393 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,11 +1,13 @@ 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 React, {memo, useEffect, useState} from 'react'; +import {View} from 'react-native'; +import type {LayoutChangeEvent, 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 variables from '@styles/variables'; import type * as OnyxTypes from '@src/types/onyx'; -import MoneyRequestReportHorizontalScrollWrapper from './MoneyRequestReportHorizontalScrollWrapper'; +import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; @@ -101,8 +103,17 @@ function MoneyRequestReportUnifiedList({ skeletonReasonAttributes, onLastItemIndexChange, }: MoneyRequestReportUnifiedListProps) { + // When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat + // would drift sideways / jump on web). Instead the FlashList virtualizes ONLY the report actions, and the table is + // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native + // horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a + // horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with + // the report actions. + const isHorizontalTable = controller.shouldScrollHorizontally && !controller.isEmptyTransactions; + const shouldInlineTransactions = !isHorizontalTable && !controller.isEmptyTransactions; + const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); - const data: UnifiedListItem[] = controller.isEmptyTransactions ? reportActionItems : [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems]; + const data: UnifiedListItem[] = shouldInlineTransactions ? [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems] : reportActionItems; // Report the last index so callers can jump to the bottom via scrollToIndex. const lastDataIndex = data.length - 1; @@ -112,7 +123,29 @@ function MoneyRequestReportUnifiedList({ }, [lastDataIndex, onLastItemIndexChange]); const lastTransactionItemIndex = controller.transactionListItems.length - 1; - const reportActionIndexOffset = controller.isEmptyTransactions ? 0 : controller.transactionListItems.length + 1; + const reportActionIndexOffset = shouldInlineTransactions ? controller.transactionListItems.length + 1 : 0; + + // Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll. + // tableOffsetTop is the height of everything above the table region (the report-fields header). + const [viewportHeight, setViewportHeight] = useState(0); + const [tableOffsetTop, setTableOffsetTop] = useState(0); + + // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders. + // Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render. + const [scrollOffsetStore] = useState(createScrollOffsetStore); + + const handleScroll = (event: NativeSyntheticEvent) => { + if (isHorizontalTable) { + // Emitter, not state: the nested FlashList updates its own render stack without re-rendering the parent. + scrollOffsetStore.setOffset(event.nativeEvent.contentOffset.y); + } + onScroll(event); + }; + + const handleLayout = (event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + onLayout(); + }; // The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset, @@ -145,46 +178,71 @@ function MoneyRequestReportUnifiedList({ const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; + // Only measure the header height when the horizontal table needs it as its offset; otherwise this state is unused. + const reportFieldsHeader = ( + setTableOffsetTop(event.nativeEvent.layout.height) : undefined}> + + + ); + return ( - - - + items={controller.transactionListItems} + keyExtractor={unifiedListKeyExtractor} + getItemType={unifiedListItemType} + renderItem={(item, _index, meta) => controller.renderTransactionListItem(item, meta)} + renderHeader={() => controller.beforeListContent} + estimatedRowHeight={variables.tableRowHeight} + contentWidth={controller.tableMinWidth} + store={scrollOffsetStore} + viewportHeight={viewportHeight} + offsetTop={tableOffsetTop} /> + {/* Rendered outside the horizontal scroller so the totals/add-expense summary stays pinned to the viewport. */} + {controller.afterListContent} + + ) : ( + <> + {reportFieldsHeader} {controller.beforeListContent} - } - keyboardShouldPersistTaps="handled" - onScroll={onScroll} - onScrollBeginDrag={onScrollBeginDrag} - onContentSizeChange={onContentSizeChange} - contentContainerStyle={contentContainerStyle} - ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} - drawDistance={1000} - /> - + ) + } + keyboardShouldPersistTaps="handled" + onScroll={handleScroll} + onScrollBeginDrag={onScrollBeginDrag} + onContentSizeChange={onContentSizeChange} + contentContainerStyle={contentContainerStyle} + ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + drawDistance={1000} + /> ); } From 1e6462b8e7063b232ec81be6ba531c49fad468b9 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 2 Jul 2026 12:42:46 +0200 Subject: [PATCH 33/48] Pin group-by/columns controls; scroll only column header with table --- .../ExternalScrollFlashListTable.tsx | 21 +++++++++++++------ .../MoneyRequestReportTransactionList.tsx | 12 +++++++++-- .../MoneyRequestReportUnifiedList.tsx | 21 +++++++++++-------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index e7052384e43c..71928aee3a6f 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -26,7 +26,9 @@ function createScrollOffsetStore(): ScrollOffsetStore { return; } offset = next; - listeners.forEach((listener) => listener()); + for (const listener of listeners) { + listener(); + } }, subscribe: (listener: () => void) => { listeners.add(listener); @@ -50,13 +52,17 @@ type MinimalScrollRef = { // `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed // call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to -// pass it as `renderScrollComponent`. -type ExternalScrollDriverProps = ScrollViewProps & { +// pass it as `renderScrollComponent`. FlashList's renderScrollComponent wrapper passes the ref as a prop, so the +// driver takes `ref` directly (React 19 style) rather than via forwardRef. +type ExternalScrollDriverProps = Omit & { /** Source of the parent list's vertical scroll offset. */ store?: ScrollOffsetStore; /** Where the table region starts within the parent page's scrollable content (px from the top). */ offsetTop?: number; + + /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */ + ref?: React.Ref; }; /** @@ -67,7 +73,7 @@ type ExternalScrollDriverProps = ScrollViewProps & { * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component: * FlashList memoizes its scroll component on identity. */ -const ExternalScrollDriver = React.forwardRef(function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style}, ref) { +function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) { const nodeRef = useRef(null); useImperativeHandle( @@ -91,6 +97,9 @@ const ExternalScrollDriver = React.forwardRef); }; // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the @@ -107,7 +116,7 @@ const ExternalScrollDriver = React.forwardRef ); -}); +} type ExternalScrollFlashListTableProps = { /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */ @@ -123,7 +132,7 @@ type ExternalScrollFlashListTableProps = { renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null; /** Column header rendered above the rows and scrolled horizontally with them. */ - renderHeader: () => React.ReactElement; + renderHeader: () => React.ReactElement | null; /** Estimated row height used before a row has been measured. */ estimatedRowHeight: number; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 41e0e50d2196..58c98fb6fd9c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -98,9 +98,12 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group: * 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). */ + /** Chrome rendered above the transaction items: group-by dropdown + columns button (or empty state). Always page-pinned. */ beforeListContent: React.ReactElement; + /** The sortable column-header row. Rendered inside the table's horizontal scroller so it tracks the columns; null on narrow layouts and empty reports. */ + tableColumnHeader: React.ReactElement | null; + /** Flat array of items to render between beforeListContent and afterListContent. */ transactionListItems: TransactionListItemData[]; @@ -952,10 +955,14 @@ function MoneyRequestReportTransactionList({ /> )} - {!shouldUseNarrowLayout && tableHeaderContent} ); + // The column-header row is kept separate from beforeListContent so the horizontal-table layout can render it + // inside the table's horizontal scroller (it must track the columns) while the group-by/columns controls above + // stay pinned to the page. In the inline layout the two are rendered back-to-back, preserving the original order. + const tableColumnHeader = isEmptyTransactions || shouldUseNarrowLayout ? null : tableHeaderContent; + const afterListContent = isEmptyTransactions ? null : ( {showPendingExpensePlaceholder && ( @@ -1060,6 +1067,7 @@ function MoneyRequestReportTransactionList({ const controller: MoneyRequestReportTransactionListController = { beforeListContent, + tableColumnHeader, transactionListItems: isEmptyTransactions ? [] : listItems, renderTransactionListItem, afterListContent, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 50838f7f8393..1df70da8d975 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -178,14 +178,11 @@ function MoneyRequestReportUnifiedList({ const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; - // Only measure the header height when the horizontal table needs it as its offset; otherwise this state is unused. const reportFieldsHeader = ( - setTableOffsetTop(event.nativeEvent.layout.height) : undefined}> - - + ); return ( @@ -212,13 +209,18 @@ function MoneyRequestReportUnifiedList({ ListHeaderComponent={ isHorizontalTable ? ( <> - {reportFieldsHeader} + {/* Report fields + group-by/columns controls stay pinned to the page; only the column header and + rows scroll horizontally. Measured together so the table's offset into the page is exact. */} + setTableOffsetTop(event.nativeEvent.layout.height)}> + {reportFieldsHeader} + {controller.beforeListContent} + items={controller.transactionListItems} keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} renderItem={(item, _index, meta) => controller.renderTransactionListItem(item, meta)} - renderHeader={() => controller.beforeListContent} + renderHeader={() => controller.tableColumnHeader} estimatedRowHeight={variables.tableRowHeight} contentWidth={controller.tableMinWidth} store={scrollOffsetStore} @@ -232,6 +234,7 @@ function MoneyRequestReportUnifiedList({ <> {reportFieldsHeader} {controller.beforeListContent} + {controller.tableColumnHeader} ) } From dc5373ac3b3607b3a03a86b3e6b6790b936d471a Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 2 Jul 2026 14:07:37 +0200 Subject: [PATCH 34/48] reset the offset --- .../MoneyRequestReportUnifiedList.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 1df70da8d975..823492d7affe 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -134,6 +134,12 @@ function MoneyRequestReportUnifiedList({ // Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render. const [scrollOffsetStore] = useState(createScrollOffsetStore); + // Reset the offset to the top whenever the report changes. A report view opens at the top, but the store only + // updates from onScroll — so without this a stale offset + useEffect(() => { + scrollOffsetStore.setOffset(0); + }, [report.reportID, scrollOffsetStore]); + const handleScroll = (event: NativeSyntheticEvent) => { if (isHorizontalTable) { // Emitter, not state: the nested FlashList updates its own render stack without re-rendering the parent. From a579798e2b812b33b9c7d18817ca082a9b646e22 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 2 Jul 2026 14:28:25 +0200 Subject: [PATCH 35/48] fix CI --- .../ExternalScrollFlashListTable.tsx | 3 +- ...eyRequestReportHorizontalScrollWrapper.tsx | 52 ---------------- .../MoneyRequestReportTransactionList.tsx | 2 +- .../MoneyRequestReportUnifiedList.tsx | 2 +- src/components/Search/index.tsx | 62 +------------------ 5 files changed, 4 insertions(+), 117 deletions(-) delete mode 100644 src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index 71928aee3a6f..116b482f1d6f 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -151,7 +151,7 @@ type ExternalScrollFlashListTableProps = { }; /** - * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled windower. + * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled virtualized list. * * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll @@ -206,4 +206,3 @@ function ExternalScrollFlashListTable({ export default ExternalScrollFlashListTable; export {createScrollOffsetStore}; -export type {ScrollOffsetStore}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx deleted file mode 100644 index 065c6393447f..000000000000 --- a/src/components/MoneyRequestReportView/MoneyRequestReportHorizontalScrollWrapper.tsx +++ /dev/null @@ -1,52 +0,0 @@ -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 58c98fb6fd9c..6e54bf987bef 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -113,7 +113,7 @@ type MoneyRequestReportTransactionListController = { /** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */ afterListContent: React.ReactElement | null; - /** True when the rendered table is wider than the viewport; the parent should wrap the list in `MoneyRequestReportHorizontalScrollWrapper`. */ + /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollFlashListTable` with its own horizontal scroller. */ shouldScrollHorizontally: boolean; /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 823492d7affe..777cec166437 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -104,7 +104,7 @@ function MoneyRequestReportUnifiedList({ onLastItemIndexChange, }: MoneyRequestReportUnifiedListProps) { // When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat - // would drift sideways / jump on web). Instead the FlashList virtualizes ONLY the report actions, and the table is + // would drift sideways / jump on web). Instead the FlashList keeps ONLY the report actions virtualized, and the table is // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native // horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a // horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 22c1a7761df8..9811f2c017b0 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1045,7 +1045,7 @@ function Search({ const isFlatExpenseView = type === CONST.SEARCH.DATA_TYPES.EXPENSE && !validGroupBy; const isExpenseGroupedView = type === CONST.SEARCH.DATA_TYPES.EXPENSE && !!validGroupBy; - // Flat-expense and grouped-expense each render through a dedicated view composed over BaseSearchList; the + // Flat-expense, grouped-expense and chat each render through a dedicated view composed over BaseSearchList; the // remaining types keep the legacy SearchList shell. The snapshot, lifecycle and selection providers stay // here so the data layer runs once. let searchListContent: React.JSX.Element; @@ -1099,66 +1099,6 @@ function Search({ isActionColumnWide={isTask || hasDeletedTransaction} /> ); - } else { - searchListContent = ( - - ); - } - - // Flat-expense and chat each render through a dedicated view composed over BaseSearchList; the remaining - // types keep the legacy SearchList shell. The snapshot, lifecycle and selection providers stay here so - // the data layer runs once. - let searchListContent: React.JSX.Element; - if (isFlatExpenseView) { - searchListContent = ( - - ); } else if (isChat) { searchListContent = ( Date: Fri, 3 Jul 2026 10:01:52 +0200 Subject: [PATCH 36/48] run fmt --- .../ExternalScrollFlashListTable.tsx | 8 +++++--- ...yRequestReportTransactionLongPressModal.tsx | 9 +++++++-- .../MoneyRequestReportUnifiedList.tsx | 18 +++++++++++++----- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index 116b482f1d6f..b17606362d5d 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -1,9 +1,11 @@ +import FlashList from '@components/FlashList'; +import ScrollView from '@components/ScrollView'; + import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; + import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; -import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; -import FlashList from '@components/FlashList'; -import ScrollView from '@components/ScrollView'; /** * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx index e92ab4cf453b..3f96a1fe6d57 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionLongPressModal.tsx @@ -1,12 +1,17 @@ -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'; +import type {Ref} from 'react'; + +import React, {useImperativeHandle, useState} from 'react'; + type MoneyRequestReportTransactionLongPressModalHandle = { show: (transactionID: string) => void; }; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 777cec166437..b94aed5a198e 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,14 +1,22 @@ -import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; -import React, {memo, useEffect, useState} from 'react'; -import {View} from 'react-native'; -import type {LayoutChangeEvent, 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 variables from '@styles/variables'; + import type * as OnyxTypes from '@src/types/onyx'; -import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; + +import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; + +import React, {memo, useEffect, useState} from 'react'; +import {View} from 'react-native'; + import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; + +import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable'; import MoneyRequestViewReportFields from './MoneyRequestViewReportFields'; import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton'; From 651521868010a2c23f1767883dd09df3e3b1dfee Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Tue, 7 Jul 2026 09:55:57 +0200 Subject: [PATCH 37/48] Fix scroll drift by skipping deferred RBR in wide transaction rows --- .../MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index 270b234ff132..eb6dd3e8d8c1 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -322,6 +322,7 @@ function MoneyRequestReportTransactionItem(props: MoneyRequestReportTransactionI ); } From 54d45e8613ce9a898f068cabf8b1876c98cad97e Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Jul 2026 10:08:49 +0200 Subject: [PATCH 38/48] fix scroll and skeleton gate --- Mobile-Expensify | 2 +- .../MoneyRequestReportActionsList.tsx | 14 ++++++++------ .../MoneyRequestReportUnifiedList.tsx | 9 ++++++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index af7837c2b37a..ee2dd9ff2168 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit af7837c2b37a25a8e26b5576d30d5751081291da +Subproject commit ee2dd9ff216862e15286568e658ba91b7a838396 diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 49e4ab8fa825..f2c310325e75 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -552,12 +552,14 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) return; } - // We want to scroll to the end of the list where the newest message is - // however scrollToEnd will not work correctly with items of variable sizes without `getItemLayout` - so we need to delay the scroll until every item rendered + // We want to scroll to the end of the list where the newest message is. We route through the indexed + // scrollToBottom (scrollToIndex) rather than scrollToEnd because scrollToEnd targets an estimated + // content-end offset that leaves the bottom blank on large transaction+chat lists. We still delay so + // the just-sent item has landed in the data before we jump. const index = visibleReportActions.findIndex((item) => item.reportActionID === reportAction?.reportActionID); if (index !== -1) { setTimeout(() => { - reportScrollManager.scrollToEnd(); + scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); } else { setEnableScrollToEnd(true); @@ -566,7 +568,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) }, }); }, - [reportScrollManager, setIsFloatingMessageCounterVisible, visibleReportActions], + [scrollToBottom, setIsFloatingMessageCounterVisible, visibleReportActions], ); useEffect(() => { @@ -592,11 +594,11 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const index = visibleReportActions.findIndex((item) => item.reportActionID === lastActionEventId); if (enableScrollToEnd && index !== -1) { setTimeout(() => { - reportScrollManager.scrollToEnd(); + scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); setEnableScrollToEnd(false); } - }, [visibleReportActions, lastActionEventId, enableScrollToEnd, reportScrollManager]); + }, [visibleReportActions, lastActionEventId, enableScrollToEnd, scrollToBottom]); const renderReportAction = useCallback( (reportAction: OnyxTypes.ReportAction, indexWithinReportActions: number) => { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index b94aed5a198e..6a000470eded 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -123,6 +123,12 @@ function MoneyRequestReportUnifiedList({ const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action})); const data: UnifiedListItem[] = shouldInlineTransactions ? [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems] : reportActionItems; + // Report actions load separately from transactions. When transactions are inlined, `data` is already non-empty + // (transaction rows + footer) while comments are still loading, so ListEmptyComponent can never surface the + // comments loading skeleton. Gate the skeleton on the report actions being empty and render it as the list footer + // (right below the transaction section, where comments will appear) in that case. + const shouldShowActionsLoadingSkeleton = !isOffline && isLoadingInitialActions && reportActionItems.length === 0; + // Report the last index so callers can jump to the bottom via scrollToIndex. const lastDataIndex = data.length - 1; @@ -257,7 +263,8 @@ function MoneyRequestReportUnifiedList({ onScrollBeginDrag={onScrollBeginDrag} onContentSizeChange={onContentSizeChange} contentContainerStyle={contentContainerStyle} - ListEmptyComponent={!isOffline && isLoadingInitialActions ? : undefined} + ListEmptyComponent={shouldShowActionsLoadingSkeleton ? : undefined} + ListFooterComponent={shouldInlineTransactions && shouldShowActionsLoadingSkeleton ? : undefined} drawDistance={1000} /> ); From d484c3e3c9272a7652c8eaf79588d4c7af7b02fd Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Jul 2026 12:49:37 +0200 Subject: [PATCH 39/48] Fix report jumping to chat on open by disabling MVCP for horizontal table --- .../MoneyRequestReportUnifiedList.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 6a000470eded..218696637d82 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -215,13 +215,7 @@ function MoneyRequestReportUnifiedList({ keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} - maintainVisibleContentPosition={{ - // In the windowed-table case `data` is report actions only, so during hydration the list briefly sits - // within the 0-threshold of the bottom and would auto-stick there (opening at the bottom of chat). - // The table lives in the header, so we want to open at the top; the app handles "jump to latest" - // explicitly (floating counter + current-user sends), so bottom auto-stick isn't needed here. - autoscrollToBottomThreshold: isHorizontalTable ? undefined : 0, - }} + maintainVisibleContentPosition={isHorizontalTable ? {disabled: true} : {autoscrollToBottomThreshold: undefined}} onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} onEndReached={onEndReached} From b5dc428eb74f73af4efcdb6085a79d5ae91e25c0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Jul 2026 13:03:54 +0200 Subject: [PATCH 40/48] Fix crash on back-navigation from report with wide transaction table --- .../ExternalScrollFlashListTable.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index b17606362d5d..cddcb4c03bb9 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -99,10 +99,17 @@ function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is // populated; windowing comes from the `overrideWindowSize` prop, not this event. const y = Math.max(0, store.getOffset() - offsetTop); - // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget are - // RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only contentOffset.y. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); + try { + // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget + // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only + // contentOffset.y. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('LayoutManager is not initialized')) { + throw error; + } + } }; // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame. From 235fa9814425fba5aaf14e986b88d6fada8399e4 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Thu, 9 Jul 2026 16:34:52 +0200 Subject: [PATCH 41/48] Make FlashList MVCP header-aware to fix deep-link scroll on wide table --- ...ash-list+2.3.0+015+mvcp-header-aware.patch | 358 ++++++++++++++++++ patches/@shopify/flash-list/details.md | 12 + .../MoneyRequestReportUnifiedList.tsx | 2 +- 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch new file mode 100644 index 000000000000..5d59a58de2ff --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch @@ -0,0 +1,358 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index 063fd1a..c37ff3a 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => { + recyclerViewContext.layout(); + } + }, [recyclerViewContext, recyclerViewManager]); ++ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component ++ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep ++ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass ++ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, ++ // so a header resize cannot shift item positions. ++ const lastHeaderSizeRef = useRef(-1); ++ const onHeaderLayout = useCallback((event) => { ++ if (inverted) { ++ return; ++ } ++ const headerSize = horizontal ++ ? event.nativeEvent.layout.width ++ : event.nativeEvent.layout.height; ++ if (lastHeaderSizeRef.current >= 0 && ++ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)) { ++ recyclerViewContext.layout(); ++ } ++ lastHeaderSizeRef.current = headerSize; ++ }, [horizontal, inverted, recyclerViewContext]); + // Get secondary props and components +- const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props); ++ const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props, onHeaderLayout); + if (!recyclerViewManager.getIsFirstLayoutComplete() && + recyclerViewManager.getDataLength() > 0) { + parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId); +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +index 40bdddb..c505e59 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + // Track the first visible item for maintaining scroll position + const firstVisibleItemKey = useRef(undefined); + const firstVisibleItemLayout = useRef(undefined); ++ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are ++ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta ++ // must be captured separately or offset correction is blind to it. ++ const firstVisibleItemFirstItemOffset = useRef(0); + // Queue to store callbacks that should be executed after scroll offset updates + const pendingScrollCallbacks = useRef([]); + // Handle initial scroll position when the list first loads +@@ -82,6 +86,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + recyclerViewManager.hasStableDataKeys() && + recyclerViewManager.getDataLength() > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition()) { ++ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the ++ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can ++ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking ++ // the viewport away from what the user is looking at. The header's own top never moves, so the correct ++ // correction while it is the anchor is none — drop the tracked item instead. ++ if (recyclerViewManager.getAbsoluteLastScrollOffset() < ++ recyclerViewManager.firstItemOffset) { ++ firstVisibleItemKey.current = undefined; ++ return; ++ } + // Update the tracked first visible item + const firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex); + if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { +@@ -90,6 +104,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(firstVisibleIndex), + }; ++ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; + } + } + }, [recyclerViewManager]); +@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + : undefined); + if (currentIndexOfFirstVisibleItem !== undefined && + currentIndexOfFirstVisibleItem >= 0) { +- // Calculate the difference in position and apply the offset +- const diff = horizontal ++ // Calculate the difference in position and apply the offset. Item layouts are header-relative, ++ // so a ListHeaderComponent resize shifts every item on screen by the same amount without ++ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. ++ const layoutDiff = horizontal + ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - + firstVisibleItemLayout.current.x + : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - + firstVisibleItemLayout.current.y; ++ const diff = layoutDiff + ++ (recyclerViewManager.firstItemOffset - ++ firstVisibleItemFirstItemOffset.current); + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), + }; ++ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset; + if (diff !== 0 && + !pauseOffsetCorrection.current && + !recyclerViewManager.animationOptimizationsEnabled) { +@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + } + else if (useAndroidInvertedFallback) { + if (!shouldSkipAndroidInvertedCorrection) { ++ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the ++ // tracker's relative offset only resyncs on scroll events), so the header delta is ++ // already embedded in it — add only the layout diff on scrollTo paths. + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + }; + if (pendingAndroidInvertedRafId.current !== null) { +@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + else { + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + animated: false, + }; + (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); + } + if (hasDataChanged) { +- updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => { }); ++ updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, () => { }); + recyclerViewManager.ignoreScrollEvents = true; + setTimeout(() => { + recyclerViewManager.ignoreScrollEvents = false; +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js +index 4d7a945..26133d6 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js +@@ -21,7 +21,7 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; + * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer + * - CompatScrollView: The animated scroll component + */ +-export function useSecondaryProps(props) { ++export function useSecondaryProps(props, onHeaderLayout) { + const { ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, ListEmptyComponentStyle, renderScrollComponent, refreshing, progressViewOffset, onRefresh, data, refreshControl: customRefreshControl, stickyHeaderConfig, inverted, horizontal, } = props; + const invertedTransformStyle = inverted + ? getInvertedTransformStyle(horizontal) +@@ -45,8 +45,8 @@ export function useSecondaryProps(props) { + if (!ListHeaderComponent) { + return null; + } +- return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle] }, getValidComponent(ListHeaderComponent))); +- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); ++ return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle], onLayout: onHeaderLayout }, getValidComponent(ListHeaderComponent))); ++ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle, onHeaderLayout]); + /** + * Creates the footer component with optional styling. + */ +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index a7829d5..ec5d7b7 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -16,6 +16,7 @@ import React, { + import { + Animated, + I18nManager, ++ LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + Platform, +@@ -501,6 +502,31 @@ const RecyclerViewComponent = ( + [recyclerViewContext, recyclerViewManager] + ); + ++ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component ++ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep ++ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass ++ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there, ++ // so a header resize cannot shift item positions. ++ const lastHeaderSizeRef = useRef(-1); ++ const onHeaderLayout = useCallback( ++ (event: LayoutChangeEvent) => { ++ if (inverted) { ++ return; ++ } ++ const headerSize = horizontal ++ ? event.nativeEvent.layout.width ++ : event.nativeEvent.layout.height; ++ if ( ++ lastHeaderSizeRef.current >= 0 && ++ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize) ++ ) { ++ recyclerViewContext.layout(); ++ } ++ lastHeaderSizeRef.current = headerSize; ++ }, ++ [horizontal, inverted, recyclerViewContext] ++ ); ++ + // Get secondary props and components + const { + refreshControl, +@@ -509,7 +535,7 @@ const RecyclerViewComponent = ( + renderEmpty, + CompatScrollView, + renderStickyHeaderBackdrop, +- } = useSecondaryProps(props); ++ } = useSecondaryProps(props, onHeaderLayout); + + if ( + !recyclerViewManager.getIsFirstLayoutComplete() && +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +index 3012391..7375752 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx +@@ -57,6 +57,10 @@ export function useRecyclerViewController( + // Track the first visible item for maintaining scroll position + const firstVisibleItemKey = useRef(undefined); + const firstVisibleItemLayout = useRef(undefined); ++ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are ++ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta ++ // must be captured separately or offset correction is blind to it. ++ const firstVisibleItemFirstItemOffset = useRef(0); + + // Queue to store callbacks that should be executed after scroll offset updates + const pendingScrollCallbacks = useRef<(() => void)[]>([]); +@@ -96,6 +100,18 @@ export function useRecyclerViewController( + recyclerViewManager.getDataLength() > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition() + ) { ++ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the ++ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can ++ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking ++ // the viewport away from what the user is looking at. The header's own top never moves, so the correct ++ // correction while it is the anchor is none — drop the tracked item instead. ++ if ( ++ recyclerViewManager.getAbsoluteLastScrollOffset() < ++ recyclerViewManager.firstItemOffset ++ ) { ++ firstVisibleItemKey.current = undefined; ++ return; ++ } + // Update the tracked first visible item + const firstVisibleIndex = Math.max( + 0, +@@ -107,6 +123,8 @@ export function useRecyclerViewController( + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(firstVisibleIndex), + }; ++ firstVisibleItemFirstItemOffset.current = ++ recyclerViewManager.firstItemOffset; + } + } + }, [recyclerViewManager]); +@@ -156,15 +174,23 @@ export function useRecyclerViewController( + currentIndexOfFirstVisibleItem !== undefined && + currentIndexOfFirstVisibleItem >= 0 + ) { +- // Calculate the difference in position and apply the offset +- const diff = horizontal ++ // Calculate the difference in position and apply the offset. Item layouts are header-relative, ++ // so a ListHeaderComponent resize shifts every item on screen by the same amount without ++ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately. ++ const layoutDiff = horizontal + ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - + firstVisibleItemLayout.current!.x + : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - + firstVisibleItemLayout.current!.y; ++ const diff = ++ layoutDiff + ++ (recyclerViewManager.firstItemOffset - ++ firstVisibleItemFirstItemOffset.current); + firstVisibleItemLayout.current = { + ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem), + }; ++ firstVisibleItemFirstItemOffset.current = ++ recyclerViewManager.firstItemOffset; + if ( + diff !== 0 && + !pauseOffsetCorrection.current && +@@ -175,20 +201,27 @@ export function useRecyclerViewController( + // console.log("scrollBy", diff); + scrollAnchorRef.current?.scrollBy(diff); + } else { ++ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the ++ // tracker's relative offset only resyncs on scroll events), so the header delta is ++ // already embedded in it — add only the layout diff on scrollTo paths. + const scrollToParams = horizontal + ? { +- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ x: ++ recyclerViewManager.getAbsoluteLastScrollOffset() + ++ layoutDiff, + animated: false, + } + : { +- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ y: ++ recyclerViewManager.getAbsoluteLastScrollOffset() + ++ layoutDiff, + animated: false, + }; + scrollViewRef.current?.scrollTo(scrollToParams); + } + if (hasDataChanged) { + updateScrollOffsetWithCallback( +- recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, + () => {} + ); + recyclerViewManager.ignoreScrollEvents = true; +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx +index a64742c..7be3eb2 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx +@@ -1,4 +1,4 @@ +-import { Animated, RefreshControl } from "react-native"; ++import { Animated, LayoutChangeEvent, RefreshControl } from "react-native"; + import React, { useMemo } from "react"; + + import { RecyclerViewProps } from "../RecyclerViewProps"; +@@ -24,7 +24,10 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle"; + * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer + * - CompatScrollView: The animated scroll component + */ +-export function useSecondaryProps(props: RecyclerViewProps) { ++export function useSecondaryProps( ++ props: RecyclerViewProps, ++ onHeaderLayout?: (event: LayoutChangeEvent) => void ++) { + const { + ListHeaderComponent, + ListHeaderComponentStyle, +@@ -73,11 +76,19 @@ export function useSecondaryProps(props: RecyclerViewProps) { + return null; + } + return ( +- ++ + {getValidComponent(ListHeaderComponent)} + + ); +- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]); ++ }, [ ++ ListHeaderComponent, ++ ListHeaderComponentStyle, ++ invertedTransformStyle, ++ onHeaderLayout, ++ ]); + + /** + * Creates the footer component with optional styling. diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index c00b52f04d5c..84eab499744e 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -163,3 +163,15 @@ - Upstream PR/issue: TBD - E/App issue: TBD - PR introducing patch: TBD + +### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch) + +- Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways: + 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it. + 2. **Wrong anchor while viewing the header**: `computeFirstVisibleIndexForOffsetCorrection` clamps the anchor to `Math.max(0, startIndex)`, so with the viewport over the header it tracked item 0 — possibly far below the fold — and "maintained" that off-screen item's position through data prepends/header growth, yanking the viewport away from the header (report opening at the top visibly jumped down to the chat). Fixed by dropping the anchor (`firstVisibleItemKey = undefined`) whenever the absolute scroll offset is smaller than `firstItemOffset`: the header is the user's visual anchor then, and its top never moves, so the correct correction is none. + Additionally, `RecyclerView` now observes the header wrapper's `onLayout` (main-axis size only, skipped for inverted lists where `firstItemOffset` is forced to 0) and triggers `recyclerViewContext.layout()` on change. Without this, a header that resizes from a commit inside its own subtree (the nested table settling) never causes the parent list to re-measure `firstItemOffset`, so items keep stale positions and `applyOffsetCorrection` never sees the delta. + Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table, where the whole table is the unified list's `ListHeaderComponent`: deep links into the report actions below the table now stay anchored while the table settles, and MVCP could be re-enabled for that mode (the `{disabled: true}` workaround is removed). +- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/hooks/useRecyclerViewController.tsx`, `src/recyclerview/hooks/useSecondaryProps.tsx`, and their `dist` counterparts. +- Upstream PR/issue: TBD +- E/App issue: TBD +- PR introducing patch: TBD diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 218696637d82..ed6caf29832f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -215,7 +215,7 @@ function MoneyRequestReportUnifiedList({ keyExtractor={unifiedListKeyExtractor} getItemType={unifiedListItemType} initialScrollIndex={initialScrollIndex} - maintainVisibleContentPosition={isHorizontalTable ? {disabled: true} : {autoscrollToBottomThreshold: undefined}} + maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}} onViewableItemsChanged={onViewableItemsChangedAdjusted} onLayout={handleLayout} onEndReached={onEndReached} From e61478a7a8452c0899837b3dea353c803d43aa84 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 11:18:42 +0200 Subject: [PATCH 42/48] Restore flexGrow1 in TableBody empty state to keep scrolling fix --- src/components/Table/TableBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Table/TableBody.tsx b/src/components/Table/TableBody.tsx index cedb22fbfb56..67f29d8bfc91 100644 --- a/src/components/Table/TableBody.tsx +++ b/src/components/Table/TableBody.tsx @@ -104,7 +104,7 @@ function TableBody({contentContainerStyle, style, .. showsVerticalScrollIndicator={false} maintainVisibleContentPosition={{disabled: true}} contentContainerStyle={[ - filteredAndSortedData.length === 0 && styles.flex1, + filteredAndSortedData.length === 0 && styles.flexGrow1, listContentContainerStyle, tableBodyContentContainerStyle, contentContainerStyle, From edf2b304d4f11e3e2d191899dbf29e621f5d4191 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 11:47:48 +0200 Subject: [PATCH 43/48] Remove unused horizontalScrollRestorationKey --- .../MoneyRequestReportTransactionList.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index d8dfabb49ffb..8d9353681060 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -129,9 +129,6 @@ type MoneyRequestReportTransactionListController = { /** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */ tableMinWidth: number; - /** 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; }; @@ -1085,7 +1082,6 @@ function MoneyRequestReportTransactionList({ afterListContent, shouldScrollHorizontally, tableMinWidth: minTableWidth, - horizontalScrollRestorationKey: sortedTransactions, isEmptyTransactions, }; From 71f12e84b31b656bbf433ad9b52e1d5319151eb0 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 11:59:34 +0200 Subject: [PATCH 44/48] Use indexed scrollToBottom for received-message autoscroll --- .../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 b5c4fdfcbb96..f387d49dbd4b 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -502,7 +502,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) reportActionsLength: reportActions.length, hasNewestReportAction, setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToEnd, + scrollToEnd: scrollToBottom, resetKey: report?.reportID ?? reportIDFromRoute ?? '', }); From 1814a1ee60a8a7ecee5693e468b43eee14713484 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 12:13:34 +0200 Subject: [PATCH 45/48] Re-anchor deep-linked report action after it paginates in --- .../MoneyRequestReportUnifiedList.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 8f841eb31b6a..0e16e9878983 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -10,7 +10,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; -import React, {memo, useEffect, useState} from 'react'; +import React, {memo, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList'; @@ -197,6 +197,27 @@ function MoneyRequestReportUnifiedList({ const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1; const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined; + // FlashList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is + // often not in `visibleReportActions` yet (it paginates in after mount), so the mount-only hint resolves to + // undefined and the list never anchors on the linked message. Re-anchor imperatively once the linked action is + // present. Guarded so it fires exactly once per linked target and never yanks the user after they've scrolled. + const hasAnchoredLinkedActionRef = useRef(false); + useEffect(() => { + hasAnchoredLinkedActionRef.current = false; + }, [linkedReportActionID]); + + useEffect(() => { + if (!linkedReportActionID || initialScrollIndex === undefined || hasAnchoredLinkedActionRef.current) { + return; + } + hasAnchoredLinkedActionRef.current = true; + // Defer to the next frame so the newly paginated-in rows are laid out before we scroll to the target. + const rafId = requestAnimationFrame(() => { + listRef?.current?.scrollToIndex({index: initialScrollIndex, animated: false}); + }); + return () => cancelAnimationFrame(rafId); + }, [linkedReportActionID, initialScrollIndex, listRef]); + const reportFieldsHeader = ( Date: Fri, 10 Jul 2026 12:24:27 +0200 Subject: [PATCH 46/48] Swallow FlashList teardown throw without string-matching the message --- .../ExternalScrollFlashListTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx index cddcb4c03bb9..e577ce444b50 100644 --- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx +++ b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx @@ -105,10 +105,10 @@ function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, // contentOffset.y. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent); - } catch (error) { - if (!(error instanceof Error) || !error.message.includes('LayoutManager is not initialized')) { - throw error; - } + } catch { + // During back-navigation teardown FlashList can be driven after its layout manager is gone, which + // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to + // window the nested list, so a dropped frame here is harmless — swallow it rather than crash. } }; // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the From 335748a5f8b1f3e1c758535e7ffd9962a3bac602 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 12:44:28 +0200 Subject: [PATCH 47/48] Seed nested list viewportHeight with window height to avoid blank first frame --- .../MoneyRequestReportUnifiedList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 0e16e9878983..4037b0f5a605 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -1,6 +1,8 @@ import FlashList from '@components/FlashList'; import type FlatListRefType from '@components/FlashList/types'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import variables from '@styles/variables'; @@ -140,7 +142,10 @@ function MoneyRequestReportUnifiedList({ // Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll. // tableOffsetTop is the height of everything above the table region (the report-fields header). - const [viewportHeight, setViewportHeight] = useState(0); + // Seed with the window height (a safe over-estimate) so the nested list windows against a non-zero viewport on the + // first paint — otherwise a height of 0 renders the transactions area blank until onLayout corrects it next frame. + const {windowHeight} = useWindowDimensions(); + const [viewportHeight, setViewportHeight] = useState(windowHeight); const [tableOffsetTop, setTableOffsetTop] = useState(0); // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders. From d87908c940d56967dad60de33cc84caf8f39b5b9 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 10 Jul 2026 12:51:55 +0200 Subject: [PATCH 48/48] Always feed scroll offset store so horizontal-table transition isn't stale --- .../MoneyRequestReportUnifiedList.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx index 4037b0f5a605..c1cc090e7fdd 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx @@ -159,10 +159,11 @@ function MoneyRequestReportUnifiedList({ }, [report.reportID, scrollOffsetStore]); const handleScroll = (event: NativeSyntheticEvent) => { - if (isHorizontalTable) { - // Emitter, not state: the nested FlashList updates its own render stack without re-rendering the parent. - scrollOffsetStore.setOffset(event.nativeEvent.contentOffset.y); - } + // Always feed the offset store (emitter, not state: the nested FlashList updates its own render stack without + // re-rendering the parent). Feed it even in inline mode — the store has no subscribers then, so this is a cheap + // write — so that if the layout flips to the horizontal table, the nested list windows against the real scroll + // offset instead of a stale 0. + scrollOffsetStore.setOffset(event.nativeEvent.contentOffset.y); onScroll(event); };