From df48eb761cf2f355604cb321759205ba1e3b998b Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 11 Aug 2026 11:48:36 +0200 Subject: [PATCH 01/13] feat: migrate to LegendList --- cspell.json | 1 + package-lock.json | 21 ++ package.json | 1 + .../CellRendererComponent.tsx | 30 --- .../FlashList/InvertedFlashList/index.tsx | 31 --- src/components/FlashList/types.ts | 28 ++- .../LegendList/InvertedLegendList/index.tsx | 198 ++++++++++++++++++ src/pages/inbox/ActionListContext.tsx | 4 +- src/pages/inbox/report/ReportActionsList.tsx | 15 +- tests/ui/ReportActionsListTest.tsx | 6 +- tests/unit/InvertedLegendListTest.tsx | 102 +++++++++ tests/unit/ReportActionsListThresholdTest.tsx | 6 +- 12 files changed, 362 insertions(+), 81 deletions(-) delete mode 100644 src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx delete mode 100644 src/components/FlashList/InvertedFlashList/index.tsx create mode 100644 src/components/LegendList/InvertedLegendList/index.tsx create mode 100644 tests/unit/InvertedLegendListTest.tsx diff --git a/cspell.json b/cspell.json index ca76c790e893..c1e5ba677453 100644 --- a/cspell.json +++ b/cspell.json @@ -718,6 +718,7 @@ "lastiPhoneLogin", "lastname", "lefthook", + "legendapp", "libc", "Libc", "libc's", diff --git a/package-lock.json b/package-lock.json index 010993bcde9b..16b860ea8403 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.0.0-beta.56", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11422,6 +11423,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@legendapp/list": { + "version": "3.0.0-beta.56", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.0.0-beta.56.tgz", + "integrity": "sha512-FcaZLq5n818/2D0VkZCmc4Y/V18/EKLLiYLRZxHgYrgaP+NtHvJ7fp+ZXpo6ae/bWLom0kCK2cM7FiIaWQLEcQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", diff --git a/package.json b/package.json index af0c2ed877b2..5f6a8ba7b8ae 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", + "@legendapp/list": "^3.0.0-beta.56", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx deleted file mode 100644 index bc11ccf61296..000000000000 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx deleted file mode 100644 index a1ef9a4898b7..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; - -import type {FlashListProps} from '@shopify/flash-list'; - -import React from 'react'; - -import FlashList from '..'; -import CellRendererComponent from './CellRendererComponent'; - -type InvertedFlashListProps = FlashListProps & { - /** The array of items to render in the list. */ - data: T[]; - - /** Function that extracts a unique key for each item in the list. */ - keyExtractor: (item: T, index: number) => string; - - /** Ref to the underlying list instance. */ - ref: FlatListRefType; -}; - -function InvertedFlashList(props: InvertedFlashListProps) { - return ( - - {...props} - inverted - CellRendererComponent={CellRendererComponent} - /> - ); -} - -export default InvertedFlashList; diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts index cf7718d3d148..a7de9269b522 100644 --- a/src/components/FlashList/types.ts +++ b/src/components/FlashList/types.ts @@ -1,7 +1,31 @@ import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; + +type ScrollToIndexParams = { + animated?: boolean; + index: number; + viewOffset?: number; + viewPosition?: number; +}; + +type ScrollToOffsetParams = { + animated?: boolean; + offset: number; +}; + +type ScrollToEndParams = { + animated?: boolean; +}; + +/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */ +type ActionListRef = { + scrollToIndex: (params: ScrollToIndexParams) => void; + scrollToOffset: (params: ScrollToOffsetParams) => void; + scrollToEnd: (params?: ScrollToEndParams) => void; + getNativeScrollRef?: () => unknown; +}; /** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; +type FlatListRefType = RefObject | null; export default FlatListRefType; +export type {ActionListRef}; diff --git a/src/components/LegendList/InvertedLegendList/index.tsx b/src/components/LegendList/InvertedLegendList/index.tsx new file mode 100644 index 000000000000..5d2e5fb2babb --- /dev/null +++ b/src/components/LegendList/InvertedLegendList/index.tsx @@ -0,0 +1,198 @@ +import type FlatListRefType from '@components/FlashList/types'; +import type {ActionListRef} from '@components/FlashList/types'; + +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; + +import type {LegendListProps, LegendListRef, LegendListRenderItemProps, OnViewableItemsChangedInfo, ViewToken as LegendListViewToken} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; + +import {LegendList} from '@legendapp/list/react-native'; +import React, {useImperativeHandle, useMemo, useRef} from 'react'; + +type InitialScrollIndexParams = { + viewOffset?: number; + viewPosition?: number; +}; + +type MaintainVisibleContentPosition = { + animateAutoScrollToBottom?: boolean; + autoscrollToBottomThreshold?: number; + disabled?: boolean; +}; + +function getOppositeIndex(itemCount: number, index: number) { + return itemCount - index - 1; +} + +function getOppositeViewPosition(viewPosition: number | undefined) { + return viewPosition === undefined ? undefined : 1 - viewPosition; +} + +function getOppositeViewOffset(viewOffset: number | undefined) { + return viewOffset === undefined ? undefined : -viewOffset; +} + +type InvertedLegendListProps = Omit< + LegendListProps, + 'data' | 'initialScrollIndex' | 'keyExtractor' | 'maintainVisibleContentPosition' | 'onViewableItemsChanged' | 'ref' | 'renderItem' +> & { + /** The array of items to render in newest-to-oldest order. */ + data: T[]; + + /** Positioning params paired with the external, inverted initial index. */ + initialScrollIndexParams?: InitialScrollIndexParams; + + /** The initial index in the external, newest-to-oldest data. */ + initialScrollIndex?: number; + + /** Function that extracts a unique key using the external, inverted index. */ + keyExtractor: (item: T, index: number) => string; + + /** FlashList-compatible visible-content configuration used by ReportActionsList. */ + maintainVisibleContentPosition?: MaintainVisibleContentPosition; + + /** Receives view tokens whose indices match the external, newest-to-oldest data. */ + onViewableItemsChanged?: (info: {viewableItems: Array>; changed: Array>}) => void; + + /** Ref consumed by the shared report scroll manager. */ + ref: FlatListRefType; + + /** Renders an item with its index in the external, newest-to-oldest data. */ + renderItem: (info: LegendListRenderItemProps) => React.ReactNode; +}; + +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type parameters inside components. + */ +function InvertedLegendListImpl({ + data, + getItemType, + initialScrollIndex, + initialScrollIndexParams, + keyExtractor, + ListFooterComponent, + ListFooterComponentStyle, + ListHeaderComponent, + ListHeaderComponentStyle, + maintainVisibleContentPosition, + onContentSizeChange, + onEndReached, + onEndReachedThreshold, + onLayout, + onScroll, + onStartReached, + onStartReachedThreshold, + onViewableItemsChanged, + ref, + renderItem, + ...restProps +}: InvertedLegendListProps) { + const legendListRef = useRef(null); + const contentHeightRef = useRef(0); + const viewportHeightRef = useRef(0); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: true}); + + const reversedData = useMemo(() => data.toReversed(), [data]); + useImperativeHandle( + ref, + (): ActionListRef => ({ + getNativeScrollRef: () => ({ + scrollToEnd: ({animated = true}: {animated?: boolean} = {}) => legendListRef.current?.scrollToIndex({animated, index: 0}), + }), + scrollToEnd: ({animated = true} = {}) => { + legendListRef.current?.scrollToIndex({animated, index: 0}); + }, + scrollToIndex: ({index, viewOffset, viewPosition, ...options}) => { + legendListRef.current?.scrollToIndex({ + ...options, + index: getOppositeIndex(data.length, index), + viewOffset: getOppositeViewOffset(viewOffset), + viewPosition: getOppositeViewPosition(viewPosition), + }); + }, + scrollToOffset: ({offset, ...options}) => { + const maxOffset = Math.max(0, contentHeightRef.current - viewportHeightRef.current); + legendListRef.current?.scrollToOffset({...options, offset: Math.max(0, maxOffset - offset)}); + }, + }), + [data.length], + ); + + const handleLayout = (event: LayoutChangeEvent) => { + viewportHeightRef.current = event.nativeEvent.layout.height; + onLayout?.(event); + }; + + const handleContentSizeChange = (width: number, height: number) => { + contentHeightRef.current = height; + onContentSizeChange?.(width, height); + }; + + const handleScroll = (event: NativeSyntheticEvent) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const maxOffset = Math.max(0, contentSize.height - layoutMeasurement.height); + const invertedEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: Math.max(0, maxOffset - contentOffset.y)}, + }, + }; + + onScroll?.(invertedEvent); + emitComposerScrollEvents(); + }; + + const mapViewToken = (token: LegendListViewToken): ViewToken => ({...token, index: getOppositeIndex(data.length, token.index)}); + const handleViewableItemsChanged = ({viewableItems, changed}: OnViewableItemsChangedInfo) => { + onViewableItemsChanged?.({ + viewableItems: viewableItems.map(mapViewToken), + changed: changed.map(mapViewToken), + }); + }; + + const legendInitialScrollIndex = + initialScrollIndex === undefined + ? undefined + : { + index: getOppositeIndex(data.length, initialScrollIndex), + viewOffset: getOppositeViewOffset(initialScrollIndexParams?.viewOffset), + viewPosition: getOppositeViewPosition(initialScrollIndexParams?.viewPosition), + }; + + return ( + + {...restProps} + ref={legendListRef} + data={reversedData} + renderItem={(info) => renderItem({...info, data, index: getOppositeIndex(data.length, info.index)})} + keyExtractor={(item, index) => keyExtractor(item, getOppositeIndex(data.length, index))} + getItemType={getItemType ? (item, index) => getItemType(item, getOppositeIndex(data.length, index)) : undefined} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={legendInitialScrollIndex} + alignItemsAtEnd={!ListHeaderComponentStyle} + maintainVisibleContentPosition={maintainVisibleContentPosition?.disabled ? false : {data: true}} + onEndReached={onStartReached ? ({distanceFromEnd}) => onStartReached({distanceFromStart: distanceFromEnd}) : undefined} + onEndReachedThreshold={onStartReachedThreshold} + onStartReached={onEndReached ? ({distanceFromStart}) => onEndReached({distanceFromEnd: distanceFromStart}) : undefined} + onStartReachedThreshold={onEndReachedThreshold} + ListHeaderComponent={ListFooterComponent} + ListHeaderComponentStyle={ListFooterComponentStyle} + ListFooterComponent={ListHeaderComponent} + ListFooterComponentStyle={ListHeaderComponentStyle} + onLayout={handleLayout} + onContentSizeChange={handleContentSizeChange} + onScroll={handleScroll} + onViewableItemsChanged={handleViewableItemsChanged} + /> + ); +} + +function InvertedLegendList(props: InvertedLegendListProps) { + // The implementation preserves T at runtime; this only erases the generic for OXC's component transform. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return )} />; +} + +export default InvertedLegendList; diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..1f9ab8877322 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,7 +1,7 @@ import type FlatListRefType from '@components/FlashList/types'; +import type {ActionListRef} from '@components/FlashList/types'; import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index b085862bb4dc..0593d574b033 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,5 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import InvertedLegendList from '@components/LegendList/InvertedLegendList'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useEnvironment from '@hooks/useEnvironment'; @@ -12,7 +12,6 @@ import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -55,7 +54,7 @@ import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -105,7 +104,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { function ReportActionsListContent({reportID, onLayout}: ReportActionsListContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); @@ -268,7 +266,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent scrollToActionBadgeTarget, flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, maintainVisibleContentPosition, @@ -355,7 +352,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); })(); - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; return ( @@ -458,7 +455,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent report={report} isReportArchived={isReportArchived} > - item.actionName} initialScrollIndex={initialScrollIndex} initialScrollIndexParams={initialScrollIndexParams} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 20ca6e71fcf5..7020e4734ebe 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -124,11 +124,11 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedFlashList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds the list via InvertedLegendList's `data`. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/FlashList/InvertedFlashList', () => jest.fn(() => null)); +jest.mock('@components/LegendList/InvertedLegendList', () => jest.fn(() => null)); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); jest.mock('@hooks/useReportActionsScroll', () => @@ -162,7 +162,7 @@ type MockInvertedFlashListProps = { renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/LegendList/InvertedLegendList'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); /** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ diff --git a/tests/unit/InvertedLegendListTest.tsx b/tests/unit/InvertedLegendListTest.tsx new file mode 100644 index 000000000000..aed90e80414f --- /dev/null +++ b/tests/unit/InvertedLegendListTest.tsx @@ -0,0 +1,102 @@ +import {act, render} from '@testing-library/react-native'; + +import type {ActionListRef} from '@components/FlashList/types'; +import InvertedLegendList from '@components/LegendList/InvertedLegendList'; + +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; +import type {ForwardedRef} from 'react'; + +import React, {createRef} from 'react'; + +type Item = { + id: string; +}; + +let mockLegendListProps: LegendListProps | undefined; +const mockLegendListRef = { + getNativeScrollRef: jest.fn(), + scrollToEnd: jest.fn(() => Promise.resolve()), + scrollToIndex: jest.fn(() => Promise.resolve()), + scrollToOffset: jest.fn(() => Promise.resolve()), +}; + +jest.mock('@legendapp/list/react-native', () => { + const react = jest.requireActual('react'); + + return { + LegendList: react.forwardRef((props: LegendListProps, ref: ForwardedRef) => { + mockLegendListProps = props; + // The production ref has additional methods that this adapter does not call. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + react.useImperativeHandle(ref, () => mockLegendListRef as unknown as LegendListRef); + return null; + }), + }; +}); + +const newestItem = {id: 'newest'}; +const middleItem = {id: 'middle'}; +const oldestItem = {id: 'oldest'}; +const data = [newestItem, middleItem, oldestItem]; + +function renderList() { + const ref = createRef(); + const renderItem = jest.fn(() => null); + const onEndReached = jest.fn(); + const onStartReached = jest.fn(); + + render( + item.id} + onEndReached={onEndReached} + onStartReached={onStartReached} + />, + ); + + const legendData = mockLegendListProps?.data; + const legendRenderItem = mockLegendListProps?.renderItem; + if (!mockLegendListProps || !legendData || !legendRenderItem) { + throw new Error('LegendList did not receive data-mode props'); + } + + return {ref, renderItem, onEndReached, onStartReached, legendData, legendRenderItem, legendProps: mockLegendListProps}; +} + +describe('InvertedLegendList', () => { + beforeEach(() => { + mockLegendListProps = undefined; + jest.clearAllMocks(); + }); + + it('renders chronological data while preserving external inverted indices', () => { + const {legendData, legendRenderItem, renderItem} = renderList(); + + expect(legendData).toEqual([oldestItem, middleItem, newestItem]); + + legendRenderItem({data: legendData, extraData: undefined, index: 0, item: oldestItem, type: undefined}); + expect(renderItem).toHaveBeenCalledWith(expect.objectContaining({data, index: 2, item: oldestItem})); + }); + + it('maps imperative indices, positions, and offsets to the chronological list', () => { + const {ref} = renderList(); + + act(() => { + ref.current?.scrollToIndex({animated: false, index: 0, viewOffset: 24, viewPosition: 1}); + }); + + expect(mockLegendListRef.scrollToIndex).toHaveBeenCalledWith({animated: false, index: 2, viewOffset: -24, viewPosition: 0}); + }); + + it('swaps pagination endpoints and their distances', () => { + const {legendProps, onEndReached, onStartReached} = renderList(); + + legendProps.onEndReached?.({distanceFromEnd: 10}); + legendProps.onStartReached?.({distanceFromStart: 20}); + + expect(onStartReached).toHaveBeenCalledWith({distanceFromStart: 10}); + expect(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 20}); + }); +}); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index ba7c5238ab7f..db71d273fa28 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -50,11 +50,13 @@ function isMvcpEnabled() { return config ? !config.disabled : undefined; } -jest.mock('@components/FlashList/InvertedFlashList', () => { +jest.mock('@components/LegendList/InvertedLegendList', () => { const {forwardRef} = jest.requireActual('react'); return { __esModule: true, - default: forwardRef((props) => { + // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + default: forwardRef((props, ref) => { capturedListProps = props; mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); return null; From 7a510770470984babd5aa9160e7662aae9a3d9c9 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 14 Aug 2026 13:52:08 +0200 Subject: [PATCH 02/13] deps: update legend list --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 16b860ea8403..f80c580e173d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.0.0-beta.56", + "@legendapp/list": "^3.3.5", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11424,9 +11424,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.0.0-beta.56", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.0.0-beta.56.tgz", - "integrity": "sha512-FcaZLq5n818/2D0VkZCmc4Y/V18/EKLLiYLRZxHgYrgaP+NtHvJ7fp+ZXpo6ae/bWLom0kCK2cM7FiIaWQLEcQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.5.tgz", + "integrity": "sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index 5f6a8ba7b8ae..71b46a0f0e57 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@fullstory/react-native": "^1.9.0", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.0.0-beta.56", + "@legendapp/list": "^3.3.5", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", From 7319180475fbd7caf4fcda301c6274c39169c85a Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 14 Aug 2026 14:26:16 +0200 Subject: [PATCH 03/13] feat: remove inverted list wrapper and update usages --- .../LegendList/InvertedLegendList/index.tsx | 198 ------------------ src/hooks/useReportActionsScroll.ts | 14 +- .../useReportScrollManager/index.native.ts | 7 +- src/hooks/useReportScrollManager/index.ts | 7 +- src/pages/inbox/report/ReportActionsList.tsx | 111 +++++++--- .../report/shouldFollowActionBadgeTarget.ts | 12 +- .../report/useFollowActionBadgeTarget.ts | 4 +- .../useReportActionsNewActionLiveTail.ts | 7 +- tests/ui/ReportActionsListTest.tsx | 74 +++++-- tests/unit/InvertedLegendListTest.tsx | 102 --------- tests/unit/ReportActionsListThresholdTest.tsx | 44 ++-- .../unit/shouldFollowActionBadgeTargetTest.ts | 8 +- 12 files changed, 193 insertions(+), 395 deletions(-) delete mode 100644 src/components/LegendList/InvertedLegendList/index.tsx delete mode 100644 tests/unit/InvertedLegendListTest.tsx diff --git a/src/components/LegendList/InvertedLegendList/index.tsx b/src/components/LegendList/InvertedLegendList/index.tsx deleted file mode 100644 index 5d2e5fb2babb..000000000000 --- a/src/components/LegendList/InvertedLegendList/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; -import type {ActionListRef} from '@components/FlashList/types'; - -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; - -import type {LegendListProps, LegendListRef, LegendListRenderItemProps, OnViewableItemsChangedInfo, ViewToken as LegendListViewToken} from '@legendapp/list/react-native'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; - -import {LegendList} from '@legendapp/list/react-native'; -import React, {useImperativeHandle, useMemo, useRef} from 'react'; - -type InitialScrollIndexParams = { - viewOffset?: number; - viewPosition?: number; -}; - -type MaintainVisibleContentPosition = { - animateAutoScrollToBottom?: boolean; - autoscrollToBottomThreshold?: number; - disabled?: boolean; -}; - -function getOppositeIndex(itemCount: number, index: number) { - return itemCount - index - 1; -} - -function getOppositeViewPosition(viewPosition: number | undefined) { - return viewPosition === undefined ? undefined : 1 - viewPosition; -} - -function getOppositeViewOffset(viewOffset: number | undefined) { - return viewOffset === undefined ? undefined : -viewOffset; -} - -type InvertedLegendListProps = Omit< - LegendListProps, - 'data' | 'initialScrollIndex' | 'keyExtractor' | 'maintainVisibleContentPosition' | 'onViewableItemsChanged' | 'ref' | 'renderItem' -> & { - /** The array of items to render in newest-to-oldest order. */ - data: T[]; - - /** Positioning params paired with the external, inverted initial index. */ - initialScrollIndexParams?: InitialScrollIndexParams; - - /** The initial index in the external, newest-to-oldest data. */ - initialScrollIndex?: number; - - /** Function that extracts a unique key using the external, inverted index. */ - keyExtractor: (item: T, index: number) => string; - - /** FlashList-compatible visible-content configuration used by ReportActionsList. */ - maintainVisibleContentPosition?: MaintainVisibleContentPosition; - - /** Receives view tokens whose indices match the external, newest-to-oldest data. */ - onViewableItemsChanged?: (info: {viewableItems: Array>; changed: Array>}) => void; - - /** Ref consumed by the shared report scroll manager. */ - ref: FlatListRefType; - - /** Renders an item with its index in the external, newest-to-oldest data. */ - renderItem: (info: LegendListRenderItemProps) => React.ReactNode; -}; - -/** - * Non-generic implementation so OXC's React Compiler can memoize the component. - * OXC bails on type parameters inside components. - */ -function InvertedLegendListImpl({ - data, - getItemType, - initialScrollIndex, - initialScrollIndexParams, - keyExtractor, - ListFooterComponent, - ListFooterComponentStyle, - ListHeaderComponent, - ListHeaderComponentStyle, - maintainVisibleContentPosition, - onContentSizeChange, - onEndReached, - onEndReachedThreshold, - onLayout, - onScroll, - onStartReached, - onStartReachedThreshold, - onViewableItemsChanged, - ref, - renderItem, - ...restProps -}: InvertedLegendListProps) { - const legendListRef = useRef(null); - const contentHeightRef = useRef(0); - const viewportHeightRef = useRef(0); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: true}); - - const reversedData = useMemo(() => data.toReversed(), [data]); - useImperativeHandle( - ref, - (): ActionListRef => ({ - getNativeScrollRef: () => ({ - scrollToEnd: ({animated = true}: {animated?: boolean} = {}) => legendListRef.current?.scrollToIndex({animated, index: 0}), - }), - scrollToEnd: ({animated = true} = {}) => { - legendListRef.current?.scrollToIndex({animated, index: 0}); - }, - scrollToIndex: ({index, viewOffset, viewPosition, ...options}) => { - legendListRef.current?.scrollToIndex({ - ...options, - index: getOppositeIndex(data.length, index), - viewOffset: getOppositeViewOffset(viewOffset), - viewPosition: getOppositeViewPosition(viewPosition), - }); - }, - scrollToOffset: ({offset, ...options}) => { - const maxOffset = Math.max(0, contentHeightRef.current - viewportHeightRef.current); - legendListRef.current?.scrollToOffset({...options, offset: Math.max(0, maxOffset - offset)}); - }, - }), - [data.length], - ); - - const handleLayout = (event: LayoutChangeEvent) => { - viewportHeightRef.current = event.nativeEvent.layout.height; - onLayout?.(event); - }; - - const handleContentSizeChange = (width: number, height: number) => { - contentHeightRef.current = height; - onContentSizeChange?.(width, height); - }; - - const handleScroll = (event: NativeSyntheticEvent) => { - const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; - const maxOffset = Math.max(0, contentSize.height - layoutMeasurement.height); - const invertedEvent = { - ...event, - nativeEvent: { - ...event.nativeEvent, - contentOffset: {...contentOffset, y: Math.max(0, maxOffset - contentOffset.y)}, - }, - }; - - onScroll?.(invertedEvent); - emitComposerScrollEvents(); - }; - - const mapViewToken = (token: LegendListViewToken): ViewToken => ({...token, index: getOppositeIndex(data.length, token.index)}); - const handleViewableItemsChanged = ({viewableItems, changed}: OnViewableItemsChangedInfo) => { - onViewableItemsChanged?.({ - viewableItems: viewableItems.map(mapViewToken), - changed: changed.map(mapViewToken), - }); - }; - - const legendInitialScrollIndex = - initialScrollIndex === undefined - ? undefined - : { - index: getOppositeIndex(data.length, initialScrollIndex), - viewOffset: getOppositeViewOffset(initialScrollIndexParams?.viewOffset), - viewPosition: getOppositeViewPosition(initialScrollIndexParams?.viewPosition), - }; - - return ( - - {...restProps} - ref={legendListRef} - data={reversedData} - renderItem={(info) => renderItem({...info, data, index: getOppositeIndex(data.length, info.index)})} - keyExtractor={(item, index) => keyExtractor(item, getOppositeIndex(data.length, index))} - getItemType={getItemType ? (item, index) => getItemType(item, getOppositeIndex(data.length, index)) : undefined} - initialScrollAtEnd={initialScrollIndex === undefined} - initialScrollIndex={legendInitialScrollIndex} - alignItemsAtEnd={!ListHeaderComponentStyle} - maintainVisibleContentPosition={maintainVisibleContentPosition?.disabled ? false : {data: true}} - onEndReached={onStartReached ? ({distanceFromEnd}) => onStartReached({distanceFromStart: distanceFromEnd}) : undefined} - onEndReachedThreshold={onStartReachedThreshold} - onStartReached={onEndReached ? ({distanceFromStart}) => onEndReached({distanceFromEnd: distanceFromStart}) : undefined} - onStartReachedThreshold={onEndReachedThreshold} - ListHeaderComponent={ListFooterComponent} - ListHeaderComponentStyle={ListFooterComponentStyle} - ListFooterComponent={ListHeaderComponent} - ListFooterComponentStyle={ListHeaderComponentStyle} - onLayout={handleLayout} - onContentSizeChange={handleContentSizeChange} - onScroll={handleScroll} - onViewableItemsChanged={handleViewableItemsChanged} - /> - ); -} - -function InvertedLegendList(props: InvertedLegendListProps) { - // The implementation preserves T at runtime; this only erases the generic for OXC's component transform. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return )} />; -} - -export default InvertedLegendList; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index f44f1d428817..3c9f1c9dd439 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -51,7 +51,7 @@ type UseReportActionsScrollParams = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** Actions actually rendered by the list (may include a synthetic draft), used for mount scroll positioning */ + /** Actions actually rendered by the list in chronological order (may include a synthetic draft), used for scroll positioning */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Extracts the list key for an action; used to locate the initial scroll target */ @@ -121,7 +121,7 @@ type UseReportActionsScrollResult = { /** The initial scroll target key for the list */ initialScrollKey: string | undefined; - /** maintainVisibleContentPosition config for the inverted list */ + /** maintainVisibleContentPosition config for the list */ maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; /** The index the list should scroll to on mount (undefined to keep default position) */ @@ -211,7 +211,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, shouldDisablePillTracking, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -232,7 +232,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -413,10 +413,10 @@ function useReportActionsScroll({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex > 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; + initialScrollIndex = 0; + initialScrollIndexParams = {viewOffset: -windowHeight}; } return { diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 97c9a92cf1f5..c1823e3dc0a8 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -21,17 +21,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..09ce64a5687c 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,17 +18,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 0593d574b033..bf2e3cc65332 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,5 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import InvertedLegendList from '@components/LegendList/InvertedLegendList'; +import type {ActionListRef} from '@components/FlashList/types'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useEnvironment from '@hooks/useEnvironment'; @@ -54,13 +54,14 @@ import type SCREENS from '@src/SCREENS'; import {getStableReportSelector} from '@src/selectors/Report'; import type * as OnyxTypes from '@src/types/onyx'; -import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; @@ -82,6 +83,8 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; +const PAGINATION_THRESHOLD = 0.75; + /** * Create a unique key for each action in the FlatList. * We use the reportActionID that is a string representation of a random 64-bit int, which should be @@ -132,9 +135,11 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {sessionStartTime} = useConciergeSessionState(); const didLayout = useRef(false); + const didReachStartRef = useRef(false); useEffect(() => { didLayout.current = false; + didReachStartRef.current = false; }, [reportID]); useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); @@ -170,6 +175,23 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); @@ -179,7 +201,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, @@ -232,6 +254,10 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return visibleReportActionsWithDraft; })(); + // Report actions are stored newest-first. LegendList intentionally has no inverted mode, so + // give it chronological data and use its normal start/end and scrolling semantics. + const listData = renderedVisibleReportActions.toReversed(); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; @@ -253,9 +279,10 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, persistedDraftReportAction, revealDraftFromReportAction]); - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + // Find the index of the action badge target in the chronological data rendered by LegendList. const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID ? renderedVisibleReportActions.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const actionBadgeTargetIndex = actionBadgeTargetID ? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const unreadMarkerListIndex = unreadMarkerReportActionID ? listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; const { trackVerticalScrolling, @@ -276,13 +303,13 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, draftAutoScrollKey, actionBadgeTargetIndex, @@ -291,18 +318,38 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }; - - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + const loadOlderChatsOnStartReached = () => { + if (showHiddenHistory || didReachStartRef.current) { return; } + + didReachStartRef.current = true; loadOlderChats(false); }; + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + const startReachedThreshold = layoutMeasurement.height * PAGINATION_THRESHOLD; + + if (contentOffset.y > startReachedThreshold) { + didReachStartRef.current = false; + } else { + loadOlderChatsOnStartReached(); + } + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, + }, + }; + + trackVerticalScrolling(bottomRelativeEvent); + setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + }; + const loadNewerChatsAfterTransitions = () => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); @@ -324,7 +371,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, actionBadge: reportAttributes?.actionBadge, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -354,6 +401,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const reportActionIndex = renderedVisibleReportActions.length - index - 1; return ( @@ -366,8 +414,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent chatReport={chatReportStable} linkedReportActionID={linkedReportActionID} displayAsGroup={ - !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, index, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && - isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, index, isOffline) + !isConsecutiveChronosAutomaticTimerAction(renderedVisibleReportActions, reportActionIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + isConsecutiveActionMadeByPreviousActor(renderedVisibleReportActions, reportActionIndex, isOffline) } shouldHideThreadDividerLine={shouldHideThreadDividerLine} shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID} @@ -455,24 +503,24 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent report={report} isReportArchived={isReportArchived} > - { recordTimeToMeasureItemLayout(event); @@ -483,9 +531,10 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent extraData={extraData} key={listID} getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} + alignItemsAtEnd={!shouldBeAlignedToTop} + maintainVisibleContentPosition={maintainVisibleContentPosition.disabled ? false : {data: true}} onLoad={onLoad} onContentSizeChange={() => { trackVerticalScrolling(undefined); diff --git a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts index 5d8940ff5020..b9b9ba105753 100644 --- a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts @@ -8,19 +8,19 @@ type ShouldFollowActionBadgeTargetParams = { /** The report action the badge targeted on the previous render */ prevActionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** Index of the previous target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the previous target in the chronological list, or -1 when it is not rendered */ prevActionBadgeTargetIndex: number; }; /** * Decide whether to auto-scroll the report list to follow the action badge once its current target is resolved. * - * The list is inverted: index 0 is the newest action at the bottom and higher indexes are older actions at the top. The badge - * always targets the oldest actionable preview, so resolving it advances the target to a newer preview (a strictly lower index). - * We only follow when the target moves to a lower index, so we scroll downward to the next actionable preview and never jump + * The list is chronological: index 0 is the oldest action and higher indexes are newer actions. The badge always targets the + * oldest actionable preview, so resolving it advances the target to a newer preview (a strictly higher index). + * We only follow when the target moves to a higher index, so we scroll downward to the next actionable preview and never jump * upward/backward (e.g. when older actions are loaded in via pagination). */ function shouldFollowActionBadgeTarget({ @@ -33,7 +33,7 @@ function shouldFollowActionBadgeTarget({ if (isProduction || !actionTargetReportActionID || !prevActionTargetReportActionID || actionTargetReportActionID === prevActionTargetReportActionID || actionBadgeTargetIndex < 0) { return false; } - return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; + return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex > prevActionBadgeTargetIndex; } export default shouldFollowActionBadgeTarget; diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index 8c6275b91721..ce4fb5cfc6a7 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -22,13 +22,13 @@ type UseFollowActionBadgeTargetParams = { /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; /** The kind of action badge currently shown, used to decide how long to wait for its resolve animation */ actionBadge: ValueOf | undefined; - /** The rendered (inverted) report actions the list is displaying */ + /** The chronological report actions the list is displaying */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Scrolls the list to the current action-badge target */ diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index b43a18e04122..450282f1bd39 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -39,7 +39,8 @@ type UseReportActionsNewActionLiveTailParams = { hasNewerActions: boolean; linkedReportActionID: string | undefined; hasNewestReportAction: boolean; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; + /** Actions rendered by the list in chronological order. */ + renderedVisibleReportActions: OnyxTypes.ReportAction[]; sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; reportActionPages: OnyxTypes.Pages | undefined; setTreatAsNoPaginationAnchor: (value: boolean) => void; @@ -68,7 +69,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -115,7 +116,7 @@ function useReportActionsNewActionLiveTail({ return; } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { if (index > 0) { setTimeout(() => { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 7020e4734ebe..c418c80716b6 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -113,9 +113,10 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +const mockLoadOlderChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => jest.fn(() => ({ - loadOlderChats: jest.fn(), + loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), })), ); @@ -124,11 +125,11 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedLegendList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds chronological data directly to LegendList. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/LegendList/InvertedLegendList', () => jest.fn(() => null)); +jest.mock('@legendapp/list/react-native', () => ({LegendList: jest.fn(() => null)})); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); jest.mock('@hooks/useReportActionsScroll', () => @@ -144,6 +145,9 @@ jest.mock('@hooks/useReportActionsScroll', () => shouldBeAlignedToTop: false, shouldFocusToTopOnMount: false, initialScrollKey: undefined, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, + maintainVisibleContentPosition: {disabled: true}, shouldAutoscrollToBottom: false, onLoad: jest.fn(), })), @@ -156,18 +160,26 @@ jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); -type MockInvertedFlashListProps = { +type MockLegendListProps = { data?: OnyxTypes.ReportAction[]; extraData?: unknown; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + onStartReached?: () => void; + onScroll?: (event: { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; + }) => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/LegendList/InvertedLegendList'); +const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); -/** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ -const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0)?.data; -const getCapturedListProps = (): MockInvertedFlashListProps | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0); +/** Returns the chronological report actions the body fed into the mocked LegendList on its latest render. */ +const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; +const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAction, index = 0): {shouldDisableContextMenuForConciergeDraft?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -329,6 +341,32 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('loads older actions at the chronological start and rearms after moving away', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + act(() => { + listProps?.onStartReached?.(); + listProps?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + act(() => { + listProps?.onScroll?.(createScrollEvent(500)); + listProps?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); + }); + describe('Concierge Draft Context Menu', () => { const conciergeDraftReportAction: OnyxTypes.ReportAction = { reportID: mockReport.reportID, @@ -666,10 +704,10 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.length).toBeGreaterThanOrEqual(1); - expect(passedActions?.at(0)?.reportActionID).toBe(CONST.CONCIERGE_GREETING_ACTION_ID); + expect(passedActions?.some((action) => action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); }); it('should not show welcome state when not in side panel', () => { @@ -733,7 +771,7 @@ describe('ReportActionsList (body)', () => { // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); // ReportActionsList should be rendered with filtered actions - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); }); }); @@ -826,7 +864,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -844,7 +882,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); @@ -877,7 +915,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // After user sends a message, the greeting stays visible alongside session actions expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); @@ -895,7 +933,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // With no session, old messages should not be shown expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -941,7 +979,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // New user with no prior messages — onboarding messages pass through (no filtering) expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); @@ -978,7 +1016,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); }); it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { diff --git a/tests/unit/InvertedLegendListTest.tsx b/tests/unit/InvertedLegendListTest.tsx deleted file mode 100644 index aed90e80414f..000000000000 --- a/tests/unit/InvertedLegendListTest.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import type {ActionListRef} from '@components/FlashList/types'; -import InvertedLegendList from '@components/LegendList/InvertedLegendList'; - -import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; -import type {ForwardedRef} from 'react'; - -import React, {createRef} from 'react'; - -type Item = { - id: string; -}; - -let mockLegendListProps: LegendListProps | undefined; -const mockLegendListRef = { - getNativeScrollRef: jest.fn(), - scrollToEnd: jest.fn(() => Promise.resolve()), - scrollToIndex: jest.fn(() => Promise.resolve()), - scrollToOffset: jest.fn(() => Promise.resolve()), -}; - -jest.mock('@legendapp/list/react-native', () => { - const react = jest.requireActual('react'); - - return { - LegendList: react.forwardRef((props: LegendListProps, ref: ForwardedRef) => { - mockLegendListProps = props; - // The production ref has additional methods that this adapter does not call. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - react.useImperativeHandle(ref, () => mockLegendListRef as unknown as LegendListRef); - return null; - }), - }; -}); - -const newestItem = {id: 'newest'}; -const middleItem = {id: 'middle'}; -const oldestItem = {id: 'oldest'}; -const data = [newestItem, middleItem, oldestItem]; - -function renderList() { - const ref = createRef(); - const renderItem = jest.fn(() => null); - const onEndReached = jest.fn(); - const onStartReached = jest.fn(); - - render( - item.id} - onEndReached={onEndReached} - onStartReached={onStartReached} - />, - ); - - const legendData = mockLegendListProps?.data; - const legendRenderItem = mockLegendListProps?.renderItem; - if (!mockLegendListProps || !legendData || !legendRenderItem) { - throw new Error('LegendList did not receive data-mode props'); - } - - return {ref, renderItem, onEndReached, onStartReached, legendData, legendRenderItem, legendProps: mockLegendListProps}; -} - -describe('InvertedLegendList', () => { - beforeEach(() => { - mockLegendListProps = undefined; - jest.clearAllMocks(); - }); - - it('renders chronological data while preserving external inverted indices', () => { - const {legendData, legendRenderItem, renderItem} = renderList(); - - expect(legendData).toEqual([oldestItem, middleItem, newestItem]); - - legendRenderItem({data: legendData, extraData: undefined, index: 0, item: oldestItem, type: undefined}); - expect(renderItem).toHaveBeenCalledWith(expect.objectContaining({data, index: 2, item: oldestItem})); - }); - - it('maps imperative indices, positions, and offsets to the chronological list', () => { - const {ref} = renderList(); - - act(() => { - ref.current?.scrollToIndex({animated: false, index: 0, viewOffset: 24, viewPosition: 1}); - }); - - expect(mockLegendListRef.scrollToIndex).toHaveBeenCalledWith({animated: false, index: 2, viewOffset: -24, viewPosition: 0}); - }); - - it('swaps pagination endpoints and their distances', () => { - const {legendProps, onEndReached, onStartReached} = renderList(); - - legendProps.onEndReached?.({distanceFromEnd: 10}); - legendProps.onStartReached?.({distanceFromStart: 20}); - - expect(onStartReached).toHaveBeenCalledWith({distanceFromStart: 10}); - expect(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 20}); - }); -}); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index db71d273fa28..849ef4c9a058 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,40 +30,56 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; -type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type ScrollEvent = { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; +}; type CapturedListProps = { - maintainVisibleContentPosition?: {disabled: boolean}; + maintainVisibleContentPosition?: boolean | {data: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`, whose -// `disabled` flag is `!(hasScrolledOverThreshold || shouldFocusToTopOnMount)`. With no deep-link the latter -// is false, so `!disabled` mirrors the boolean under test. +// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`. With no +// deep-link, it is enabled exactly when `hasScrolledOverThreshold` is true. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the +// Every value the maintain-visible-content-position flag has held, in render order. `[0]` is the // value on the list's very first render — the property that matters, since it must be right before any effect runs. let mockMvcpHistory: Array = []; -// `!disabled` from the captured `maintainVisibleContentPosition`, or `undefined` before the list first renders. +// Whether the captured LegendList configuration enables maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; + return config === undefined ? undefined : config !== false; } -jest.mock('@components/LegendList/InvertedLegendList', () => { +jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); return { - __esModule: true, // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. // eslint-disable-next-line @typescript-eslint/no-unused-vars - default: forwardRef((props, ref) => { + LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); + mockMvcpHistory.push(props.maintainVisibleContentPosition === undefined ? undefined : props.maintainVisibleContentPosition !== false); return null; }), }; }); +function createScrollEvent(distanceFromBottom: number): ScrollEvent { + const contentHeight = 1000; + const viewportHeight = 500; + return { + nativeEvent: { + contentOffset: {x: 0, y: contentHeight - viewportHeight - distanceFromBottom}, + contentSize: {height: contentHeight, width: 300}, + layoutMeasurement: {height: viewportHeight, width: 300}, + }, + }; +} + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -165,12 +181,12 @@ describe('ReportActionsList hasScrolledOverThreshold', () => { expect(isMvcpEnabled()).toBe(false); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); expect(isMvcpEnabled()).toBe(false); }); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index e0ab679a0ce4..2f2b9328b90c 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -4,17 +4,17 @@ const BASE_PARAMS = { isProduction: false, actionTargetReportActionID: '200', prevActionTargetReportActionID: '100', - actionBadgeTargetIndex: 2, + actionBadgeTargetIndex: 7, prevActionBadgeTargetIndex: 5, }; describe('shouldFollowActionBadgeTarget', () => { - it('follows the target when it advances to a newer (lower-index) preview', () => { + it('follows the target when it advances to a newer (higher-index) preview', () => { expect(shouldFollowActionBadgeTarget(BASE_PARAMS)).toBe(true); }); - it('does not follow when the target moves to an older (higher-index) preview, e.g. while paginating', () => { - expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 7})).toBe(false); + it('does not follow when the target moves to an older (lower-index) preview, e.g. while paginating', () => { + expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 2})).toBe(false); }); it('does not follow when the target index is unchanged', () => { From 85bfd02d670ca3f25c1a8bba0d4e3f730f41c853 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 14 Aug 2026 15:26:03 +0200 Subject: [PATCH 04/13] chore: add Fullstory patch --- ...+1.9.0+002+stable-static-ref-wrapper.patch | 103 ++++++++++++++++++ patches/@fullstory/react-native/details.md | 10 ++ 2 files changed, 113 insertions(+) create mode 100644 patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch diff --git a/patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch b/patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch new file mode 100644 index 000000000000..52b2bb421663 --- /dev/null +++ b/patches/@fullstory/react-native/@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch @@ -0,0 +1,103 @@ +diff --git a/node_modules/@fullstory/react-native/src/index.ts b/node_modules/@fullstory/react-native/src/index.ts +--- a/node_modules/@fullstory/react-native/src/index.ts ++++ b/node_modules/@fullstory/react-native/src/index.ts +@@ -118,6 +118,8 @@ type FSNativeElement = ComponentRef & { + currentProps?: Record; + }; + ++const staticRefWrappers = new WeakMap>(); ++ + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element: FSNativeElement | null) { + if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) { +@@ -185,6 +187,13 @@ export function applyFSPropertiesWithRef( + return existingRef; + } + ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } ++ + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { + return sharedRefWrapper; +@@ -207,6 +216,10 @@ export function applyFSPropertiesWithRef( + configurable: false, + }); + ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } ++ + return refWrapper; + } + +diff --git a/node_modules/@fullstory/react-native/lib/commonjs/index.js b/node_modules/@fullstory/react-native/lib/commonjs/index.js +--- a/node_modules/@fullstory/react-native/lib/commonjs/index.js ++++ b/node_modules/@fullstory/react-native/lib/commonjs/index.js +@@ -83,6 +83,7 @@ try { + getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; + } catch (e) {} + const FS_REF_SYMBOL = exports.FS_REF_SYMBOL = Symbol('fullstory.ref'); ++const staticRefWrappers = new WeakMap(); + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element) { + if (element && _fullstoryInterface.isTurboModuleEnabled && _reactNative.Platform.OS === 'ios' && !_reactNative.Platform.isTV) { +@@ -136,5 +137,11 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) { + return existingRef; + } ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } + + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { +@@ -156,6 +163,9 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) { + writable: false, + configurable: false + }); ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } + return refWrapper; + } + const FullstoryAPI = { +diff --git a/node_modules/@fullstory/react-native/lib/module/index.js b/node_modules/@fullstory/react-native/lib/module/index.js +--- a/node_modules/@fullstory/react-native/lib/module/index.js ++++ b/node_modules/@fullstory/react-native/lib/module/index.js +@@ -67,6 +67,7 @@ try { + getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance; + } catch (e) {} + export const FS_REF_SYMBOL = Symbol('fullstory.ref'); ++const staticRefWrappers = new WeakMap(); + // Shared wrapper for components without refs (most common case) + function sharedRefWrapper(element) { + if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) { +@@ -120,5 +121,11 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru + return existingRef; + } ++ if (existingRef && !hasDynamicAttributes) { ++ const staticRefWrapper = staticRefWrappers.get(existingRef); ++ if (staticRefWrapper) { ++ return staticRefWrapper; ++ } ++ } + + // Use shared wrapper for null/undefined refs or static attributes + if (!existingRef && !hasDynamicAttributes) { +@@ -140,6 +147,9 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru + writable: false, + configurable: false + }); ++ if (existingRef && !hasDynamicAttributes) { ++ staticRefWrappers.set(existingRef, refWrapper); ++ } + return refWrapper; + } + const FullstoryAPI = { diff --git a/patches/@fullstory/react-native/details.md b/patches/@fullstory/react-native/details.md index f7fbb0b65bcf..74d10e93be4f 100644 --- a/patches/@fullstory/react-native/details.md +++ b/patches/@fullstory/react-native/details.md @@ -26,3 +26,13 @@ - Upstream PR/issue: 🛑 TODO - E/App issue: https://github.com/Expensify/App/issues/91225 - PR introducing patch: 🛑 TODO + +### [@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch](@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch) + +- Reason: + + FullStory creates a new callback wrapper every time `applyFSPropertiesWithRef()` receives an existing ref, including when annotations are static. Components that update state from their ref callback can then enter a detach/render/attach loop because the wrapper identity changes on every render. Cache wrappers for stable refs when annotations are static, while continuing to recreate wrappers for dynamic FullStory attributes. + +- Upstream PR/issue: - +- E/App issue: - +- PR introducing patch: - From 77288fa158c4ea4e74adb8dbb61946ecb67e2240 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 14 Aug 2026 15:26:16 +0200 Subject: [PATCH 05/13] test: add unit tests for fullstory bug --- tests/unit/FullstoryTest.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/FullstoryTest.ts b/tests/unit/FullstoryTest.ts index 4874ce9705e0..9737920cf9f2 100644 --- a/tests/unit/FullstoryTest.ts +++ b/tests/unit/FullstoryTest.ts @@ -4,10 +4,20 @@ import {shouldInitializeFullstory} from '@libs/Fullstory/common'; import CONST from '@src/CONST'; import type {Session, UserMetadata} from '@src/types/onyx'; +import type * as FullstoryReactNative from '@fullstory/react-native/src/index'; + const regularSession: Session = {authToken: 'token', accountID: 1, creationDate: Date.now()}; const supportSession: Session = {authTokenType: CONST.AUTH_TOKEN_TYPES.SUPPORT, authToken: 'supportToken', accountID: 2, creationDate: Date.now()}; describe('Fullstory', () => { + test('reuses the ref wrapper for static annotations', () => { + const {applyFSPropertiesWithRef} = jest.requireActual('@fullstory/react-native/src/index'); + const ref = jest.fn(); + + expect(applyFSPropertiesWithRef(ref, false)).toBe(applyFSPropertiesWithRef(ref, false)); + expect(applyFSPropertiesWithRef(ref, true)).not.toBe(applyFSPropertiesWithRef(ref, true)); + }); + describe('shouldInitializeFullstory', () => { const productionEnv = CONST.ENVIRONMENT.PRODUCTION; From 76eee759cfcdf9cf3431e55ffdbdf59e1623b732 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 14 Aug 2026 17:53:31 +0200 Subject: [PATCH 06/13] fix: keep list scrolled to end --- src/pages/inbox/report/ReportActionsList.tsx | 1 + tests/ui/ReportActionsListTest.tsx | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bf2e3cc65332..325f29a4083e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -534,6 +534,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} alignItemsAtEnd={!shouldBeAlignedToTop} + maintainScrollAtEnd={{animated: false, on: {layout: true}}} maintainVisibleContentPosition={maintainVisibleContentPosition.disabled ? false : {data: true}} onLoad={onLoad} onContentSizeChange={() => { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c418c80716b6..9a285e6be06f 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -163,6 +163,7 @@ jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => nul type MockLegendListProps = { data?: OnyxTypes.ReportAction[]; extraData?: unknown; + maintainScrollAtEnd?: {animated: boolean; on: {layout: boolean}}; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; onScroll?: (event: { @@ -341,6 +342,13 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('keeps the latest messages anchored when the keyboard changes the list layout', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {layout: true}}); + }); + it('loads older actions at the chronological start and rearms after moving away', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); From 9e5926b1348ea925a30a3e3d3282ca79c74fcf54 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 17 Aug 2026 11:02:38 +0200 Subject: [PATCH 07/13] fix: loading older report actions --- src/hooks/useReportActionsListModel.ts | 2 + src/pages/inbox/report/ReportActionsList.tsx | 18 ++++---- tests/ui/ReportActionsListTest.tsx | 45 +++++++++++++++++--- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..1eed3bf5375d 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -146,7 +146,9 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 325f29a4083e..fb2a62480ed8 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -113,7 +113,9 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const { report, hasOnceLoadedReportActions, + hasOlderActions, hasNewerActions, + oldestReportActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -135,11 +137,11 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {sessionStartTime} = useConciergeSessionState(); const didLayout = useRef(false); - const didReachStartRef = useRef(false); + const lastRequestedOldestActionIDRef = useRef(undefined); useEffect(() => { didLayout.current = false; - didReachStartRef.current = false; + lastRequestedOldestActionIDRef.current = undefined; }, [reportID]); useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); @@ -319,23 +321,23 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent }); const loadOlderChatsOnStartReached = () => { - if (showHiddenHistory || didReachStartRef.current) { + if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; } - didReachStartRef.current = true; + lastRequestedOldestActionIDRef.current = oldestReportActionID; loadOlderChats(false); }; const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); - const startReachedThreshold = layoutMeasurement.height * PAGINATION_THRESHOLD; + const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; - if (contentOffset.y > startReachedThreshold) { - didReachStartRef.current = false; - } else { + if (isNearStart) { loadOlderChatsOnStartReached(); + } else { + lastRequestedOldestActionIDRef.current = undefined; } const bottomRelativeEvent = { diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 9a285e6be06f..7513e5241238 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -115,9 +115,10 @@ jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); const mockLoadOlderChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => - jest.fn(() => ({ + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -246,6 +247,19 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; +const olderMockReportAction: OnyxTypes.ReportAction = { + reportActionID: '0', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2022-12-31', + actorAccountID: 125, + message: [{type: 'COMMENT', html: 'Older message', text: 'Older message'}], + originalMessage: {}, + shouldShow: true, + person: [{type: 'TEXT', style: 'strong', text: 'Older User'}], + pendingAction: null, + errors: {}, +}; + const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; return render(); @@ -349,9 +363,14 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {layout: true}}); }); - it('loads older actions at the chronological start and rearms after moving away', () => { + it('continues loading older pages from scroll events when LegendList does not report reaching the start', () => { mockUseNetwork.mockReturnValue({isOffline: false}); - renderReportActionsList(); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + const view = renderReportActionsList(); const listProps = getCapturedListProps(); const createScrollEvent = (offset: number) => ({ @@ -363,15 +382,31 @@ describe('ReportActionsList (body)', () => { }); act(() => { - listProps?.onStartReached?.(); listProps?.onScroll?.(createScrollEvent(0)); }); expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); act(() => { - listProps?.onScroll?.(createScrollEvent(500)); + listProps?.onStartReached?.(); listProps?.onScroll?.(createScrollEvent(0)); }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + hasOlderActions: true, + }); + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); }); From e676505b65a71213d789e0a550e3e2abe2296ad1 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 17 Aug 2026 13:21:28 +0200 Subject: [PATCH 08/13] refactor: decouple composer scroll events from list inversion --- src/components/FlashList/index.tsx | 2 +- src/components/FlatList/FlatList/index.ios.tsx | 2 +- src/components/FlatList/FlatList/index.tsx | 2 +- src/components/KeyboardDismissibleFlatList/index.tsx | 2 +- src/hooks/useEmitComposerScrollEvents/index.ts | 11 ++++------- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index e98507c9a3bd..f9a7b9528735 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; import React from 'react'; function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..7892a044825c 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -42,7 +42,7 @@ function CustomFlatList({ [onMomentumScrollEnd], ); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fb977c60a232..9c13ff4c7068 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -245,7 +245,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 043dba4d39db..3e8109d2d0e6 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/hooks/useEmitComposerScrollEvents/index.ts b/src/hooks/useEmitComposerScrollEvents/index.ts index ba65bfcdcf5e..cfd42ee98773 100644 --- a/src/hooks/useEmitComposerScrollEvents/index.ts +++ b/src/hooks/useEmitComposerScrollEvents/index.ts @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native'; type UseEmitComposerScrollEventsOptions = { enabled?: boolean; - inverted: boolean | null | undefined; }; /** * This is used to trigger scroll behavior in the composer on web. On native, this is a no-op. - * The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer. * Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`, * we need to emit the scroll events inside the scroll handler of the specific implementation. - * @param inverted - Whether the list is inverted. * @returns A function that can be used to emit the scroll events. */ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) { - const {enabled = true, inverted} = options ?? {}; + const {enabled = true} = options ?? {}; const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * invokes the onScroll callback function from props. */ const onScroll = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * Emits when the scrolling has ended. */ const onScrollEnd = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * */ const emitComposerScrollEvents = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } From 1cb89bff59228c5cdc96e32712bffaa0dfcb71da Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 17 Aug 2026 13:21:58 +0200 Subject: [PATCH 09/13] fix: preserve chat behavior with LegendList --- .../MoneyRequestReportActionsList.tsx | 2 +- src/hooks/useReportActionsListModel.ts | 2 + src/hooks/useReportActionsScroll.ts | 96 ++++--------------- .../ReportActionCompose/useEditMessage.ts | 2 +- .../inbox/report/ReportActionIndexContext.tsx | 9 +- .../report/ReportActionItemMessageEdit.tsx | 4 +- src/pages/inbox/report/ReportActionsList.tsx | 28 ++++-- .../useReportActionsNewActionLiveTail.ts | 4 +- .../useReportUnreadMessageScrollTracking.ts | 4 +- 9 files changed, 52 insertions(+), 99 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 41abe12d930f..37c812e9748f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -634,7 +634,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; return ( - + void; }; @@ -156,7 +149,6 @@ function useReportActionsScroll({ }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useActionListContext(); - const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const backTo = route?.params?.backTo; @@ -193,17 +185,8 @@ function useReportActionsScroll({ const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey; const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); - const maintainVisibleContentPosition = { - disabled: !shouldMaintainVisibleContentPosition, - // Focus-to-top mode: once autoscroll is released, keep the threshold at 0 rather than - // removing it — FlashList only clears its pending-autoscroll flag while threshold >= 0, - // otherwise the next content change (e.g. mark-as-unread) scrolls back to top. - ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), - }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = useReportUnreadMessageScrollTracking({ reportID, @@ -270,29 +253,6 @@ function useReportActionsScroll({ }); }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - const scheduleInitialScrollToBottom = useEffectEvent(() => { - if (initialScrollKey) { - return undefined; - } - - return TransitionTracker.runAfterTransitions({ - callback: () => { - if (shouldFocusToTopOnMount) { - return; - } - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - }, - waitForUpcomingTransition: true, - }); - }); - - // The initial scroll-to-bottom must be scheduled exactly once, on mount; re-running it as deps change would yank the user back down while they read history. - useEffect(() => { - const handle = scheduleInitialScrollToBottom(); - return () => handle?.cancel(); - }, []); - // Fixes Safari-specific issue where the whisper option is not highlighted correctly on hover after adding new transaction. // https://github.com/Expensify/App/issues/54520 useEffect(() => { @@ -359,7 +319,7 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; const flushPendingScrollToBottom = () => { @@ -371,52 +331,30 @@ function useReportActionsScroll({ completeLiveTailPruneAfterScrollToBottom(); }; - // Data is ready at the moment FlashList finishes its first render. + // Data is ready when LegendList finishes its first render. const onLoad = () => { - if (shouldDisablePillTracking) { - // Wait one frame so the initial positioning can settle, then disable it. - requestAnimationFrame(() => { - setShouldDisablePillTracking(false); - updatePillVisibility(); - }); - } - if (!shouldFocusToTopOnMount) { + if (!shouldDisablePillTracking) { return; } - if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { - return; - } - // Wait one frame so the initial autoscroll-to-top can settle, then disable it. - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }; - const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); - // Data finished initial loading after the list mounted. onLoad has already fired, so we need - // a separate trigger to turn off autoscroll-to-top. - useEffect(() => { - if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { - return; - } - if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); + // Wait one frame so the initial positioning can settle, then disable it. + requestAnimationFrame(() => { + setShouldDisablePillTracking(false); + updatePillVisibility(); + }); + }; // Decide where the list should be positioned on mount. - // 1. If we're opening a linked message (initialScrollKey), find that action in the list and scroll it to the top - // of the viewport (viewPosition: 1) with a small offset so the message above is partly visible. - // 2. Otherwise, if the report should be opened at top (ex: for transaction threads), scroll to the top message and offset by - // the window height so we land at top of the top message for sure. + // 1. If we're opening a linked or unread message, find that action in the chronological list. + // 2. Otherwise, aligned-to-top reports start at the first action. const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; let initialScrollIndex: number | undefined; let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - if (targetIndex > 0) { + if (targetIndex >= 0) { initialScrollIndex = targetIndex; initialScrollIndexParams = {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = 0; - initialScrollIndexParams = {viewOffset: -windowHeight}; } return { @@ -428,9 +366,7 @@ function useReportActionsScroll({ scrollToActionBadgeTarget, flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, + shouldMaintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 8b11d34266ee..5a515fdcbed2 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -58,7 +58,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (shouldScrollToLastMessage) { - reportScrollManager.scrollToIndex(0); + reportScrollManager.scrollToBottom(); } } diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 1abe9823fcf3..33dcc1060c6e 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,13 +1,18 @@ import {createContext} from 'react'; /** - * Carries an action item's position index from the list renderer down to the rare consumers that + * Carries an action item's position from the list renderer down to the rare consumers that * actually need it (e.g. `ReportActionItemMessageEdit` for scroll-to-index during edit mode). * * Using context keeps `index` out of the prop signatures of every intermediate component, so a * position shift caused by a new message arriving doesn't cascade re-renders through items that * never read it. Only components that `useContext(ReportActionIndexContext)` re-render on change. */ -const ReportActionIndexContext = createContext(0); +type ReportActionPosition = { + index: number; + isNewest: boolean; +}; + +const ReportActionIndexContext = createContext({index: 0, isNewest: false}); export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 8d97141d9f65..b80fc0374f01 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -88,7 +88,7 @@ const DEFAULT_MODAL_VALUE = { }; function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { - const index = useContext(ReportActionIndexContext); + const {index, isNewest} = useContext(ReportActionIndexContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`); @@ -260,7 +260,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy reportID, originalReportID, reportAction: action, - shouldScrollToLastMessage: index === 0, + shouldScrollToLastMessage: isNewest, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index fb2a62480ed8..7e9056606bb5 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,6 +2,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@compon import type {ActionListRef} from '@components/FlashList/types'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; import useLocalize from '@hooks/useLocalize'; @@ -86,12 +87,12 @@ type ReportActionsListProps = ReportActionsListContentProps; const PAGINATION_THRESHOLD = 0.75; /** - * Create a unique key for each action in the FlatList. + * Create a unique key for each action in the list. * We use the reportActionID that is a string representation of a random 64-bit int, which should be * random enough to avoid collisions */ function keyExtractor(item: OnyxTypes.ReportAction): string { - // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell + // A report has exactly one CREATED action. Using a stable key lets the list recycle the same cell // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { return CONST.REPORT.ACTIONS.TYPE.CREATED; @@ -115,6 +116,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent hasOnceLoadedReportActions, hasOlderActions, hasNewerActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, oldestReportActionID, sortedAllReportActions, oldestUnreadReportAction, @@ -138,12 +141,20 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const didLayout = useRef(false); const lastRequestedOldestActionIDRef = useRef(undefined); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useEffect(() => { didLayout.current = false; lastRequestedOldestActionIDRef.current = undefined; }, [reportID]); + useEffect(() => { + if (isLoadingOlderReportActions && !hasLoadingOlderReportActionsError) { + return; + } + lastRequestedOldestActionIDRef.current = undefined; + }, [isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); + useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. @@ -297,7 +308,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, + shouldMaintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -350,6 +361,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent trackVerticalScrolling(bottomRelativeEvent); setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); }; const loadNewerChatsAfterTransitions = () => { @@ -406,7 +418,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const reportActionIndex = renderedVisibleReportActions.length - index - 1; return ( - + { trackVerticalScrolling(undefined); diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index 450282f1bd39..1d020b301369 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -118,12 +118,12 @@ function useReportActionsNewActionLiveTail({ const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { + setIsFloatingMessageCounterVisible(false); + if (index >= 0 && index < renderedVisibleReportActions.length - 1) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } if (action?.reportActionID) { diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 6a0385d271dc..a7655a4ba956 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -159,11 +159,9 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above the viewport. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex; setIsActionBadgeAboveViewport(isAbove); } else { From b348e3aad000ef74f244f0434f49804c2b4df5fb Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 17 Aug 2026 13:22:28 +0200 Subject: [PATCH 10/13] test: cover LegendList report behavior --- jest/setup.ts | 50 +++++++ tests/ui/PaginationTest.tsx | 17 +-- tests/ui/ReportActionsListTest.tsx | 15 ++- tests/unit/hooks/useEditMessage.test.ts | 16 ++- tests/unit/useReportActionsScrollTest.tsx | 123 +++--------------- ...seReportUnreadMessageScrollTrackingTest.ts | 48 +++++++ 6 files changed, 147 insertions(+), 122 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 76b88f58d8e6..90ce693cb98a 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -30,6 +30,56 @@ if (!('GITHUB_REPOSITORY' in process.env)) { setupMockImages(); mockFSLibrary(); +// LegendList relies on native layout measurements that Jest does not produce. FlatList gives full-app tests +// a deterministic renderer while preserving the scroll callbacks used by the report list. +jest.mock('@legendapp/list/react-native', () => { + const ReactActual = jest.requireActual('react'); + const {FlatList} = jest.requireActual('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); + + type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & { + alignItemsAtEnd?: boolean; + data?: ArrayLike; + initialScrollAtEnd?: boolean; + initialScrollIndex?: number | {index: number}; + maintainScrollAtEnd?: unknown; + maintainVisibleContentPosition?: unknown; + onScroll?: (event: import('react-native').NativeSyntheticEvent) => void; + }; + + return { + ...LegendListActual, + LegendList: ReactActual.forwardRef, MockLegendListProps>( + ( + { + alignItemsAtEnd, + data = [], + initialScrollAtEnd, + initialScrollIndex, + maintainScrollAtEnd, + maintainVisibleContentPosition, + onEndReached, + onEndReachedThreshold = 0, + onScroll, + ...props + }, + ref, + ) => { + const handleScroll = (event: import('react-native').NativeSyntheticEvent) => { + onScroll?.(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y; + if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) { + onEndReached?.({distanceFromEnd}); + } + }; + + return ReactActual.createElement(FlatList, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref}); + }, + ), + }; +}); + Object.assign(global, {TextDecoder, TextEncoder}); // This mock is required as per setup instructions for react-navigation testing diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 375441a1e3ef..b805bb77a0f1 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -44,6 +44,7 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -316,7 +317,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(LIST_END_OFFSET); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); @@ -340,7 +341,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(0); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -371,8 +372,8 @@ describe('Pagination', () => { (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); }); // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onStartReached` which triggers other updates - scrollToOffset(0); + // we need to scroll too to trigger `onEndReached` which triggers other updates + scrollToOffset(LIST_END_OFFSET); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); await waitForNetworkPromises(); @@ -394,10 +395,10 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. - scrollToOffset(500); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. expect(getReportActions()).toHaveLength(10); @@ -406,10 +407,10 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); - await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); + scrollToOffset(LIST_END_OFFSET); + await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 7513e5241238..3ca064006a24 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -86,7 +86,9 @@ const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunc const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; + return selector === reportActionsListLoadingStateSelector + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -144,12 +146,9 @@ jest.mock('@hooks/useReportActionsScroll', () => scrollToActionBadgeTarget: jest.fn(), flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, initialScrollIndex: undefined, initialScrollIndexParams: undefined, - maintainVisibleContentPosition: {disabled: true}, - shouldAutoscrollToBottom: false, + shouldMaintainVisibleContentPosition: false, onLoad: jest.fn(), })), ); @@ -164,7 +163,8 @@ jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => nul type MockLegendListProps = { data?: OnyxTypes.ReportAction[]; extraData?: unknown; - maintainScrollAtEnd?: {animated: boolean; on: {layout: boolean}}; + maintainScrollAtEnd?: {animated: boolean}; + maintainScrollAtEndThreshold?: number; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; onScroll?: (event: { @@ -360,7 +360,8 @@ describe('ReportActionsList (body)', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); - expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false, on: {layout: true}}); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(1); }); it('continues loading older pages from scroll events when LegendList does not report reaching the start', () => { diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index 11cba853d6d4..de313e5ee75e 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -55,9 +55,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToBottom: mockScrollToBottom}), })); jest.mock('@libs/ReportUtils', () => { @@ -138,4 +139,17 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to the bottom after deleting the newest message draft', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 0dadf5afdf1a..287cd5adcd86 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -47,6 +47,7 @@ jest.mock('@hooks/useReportScrollManager', () => ({ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ @@ -57,6 +58,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }), })); @@ -216,10 +218,6 @@ function flushTransitions() { }); } -function setReportLoadingState(value: {isLoadingInitialReportActions?: boolean; hasOnceLoadedReportActions?: boolean}) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, value); -} - describe('useReportActionsScroll', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -254,9 +252,7 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldFocusToTopOnMount).toBe(false); - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); + expect(result.current.shouldMaintainVisibleContentPosition).toBe(false); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -265,8 +261,7 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(true); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + expect(result.current.shouldMaintainVisibleContentPosition).toBe(true); }); it('is aligned to top for a money request report', async () => { @@ -285,22 +280,25 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('positions a linked report action at chronological index zero', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); - it('falls back to the unread marker action as the initial scroll key', async () => { + it('positions an unread marker at chronological index zero', async () => { + const unreadAction = makeAction(UNREAD_ACTION_ID); const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, - sortedVisibleReportActions: [makeAction(UNREAD_ACTION_ID)], + sortedVisibleReportActions: [unreadAction], + renderedVisibleReportActions: [unreadAction], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + expect(result.current.initialScrollIndex).toBe(0); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -311,9 +309,8 @@ describe('useReportActionsScroll', () => { sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID, {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED})], }); - expect(result.current.initialScrollKey).toBeUndefined(); - // No key + aligned-to-top → focus to top. - expect(result.current.shouldFocusToTopOnMount).toBe(true); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); }); @@ -376,7 +373,7 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); @@ -408,93 +405,7 @@ describe('useReportActionsScroll', () => { }); }); - describe('onLoad', () => { - it('does nothing when the list is not configured to focus to top on mount', async () => { - const {result} = await renderScroll(); - act(() => { - result.current.onLoad(); - }); - - // Stays disabled with no autoscroll threshold for a regular chat. - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); - }); - - it('waits for the report actions to have loaded before disabling autoscroll-to-top', async () => { - mockIsTransactionThread = true; - // No loading state → onLoad bails. - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }); - - it('disables autoscroll-to-top after a frame once report actions have loaded', async () => { - mockIsTransactionThread = true; - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - - it('disables autoscroll-to-top when report actions finish loading after the list has mounted', async () => { - mockIsTransactionThread = true; - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Load completes after mount → companion effect turns autoscroll off. - await act(async () => { - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - await waitForBatchedUpdates(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - }); - describe('effects', () => { - it('schedules an initial scroll-to-bottom on mount for a regular chat report', async () => { - await renderScroll(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - flushTransitions(); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to bottom on mount when there is an initial scroll key', async () => { - mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - - await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); - - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { mockScrollOffsetRef.current = 0; diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 0266494b66a6..6a405a348158 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -225,6 +225,32 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(onUnreadActionVisibleLocalMockFn).toHaveBeenCalledTimes(1); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + + it('tracks an unread marker in a chronological list', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + unreadMarkerReportActionIndex: 5, + isInverted: false, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'reportActions_5', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisibleMockFn).toHaveBeenCalled(); + }); }); describe('action badge above viewport tracking', () => { @@ -279,6 +305,28 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + it('returns isActionBadgeAboveViewport as true for a lower index above a chronological viewport', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + actionBadgeTargetIndex: 1, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + + expect(result.current.isActionBadgeAboveViewport).toBe(true); + }); + it('returns isActionBadgeAboveViewport as false when action badge target is visible in viewport', () => { const offsetRef = {current: 0}; const {result} = renderHook(() => From 48323a98582d00d083fc6c79b653ce5a05b4bf47 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 18 Aug 2026 13:15:44 +0200 Subject: [PATCH 11/13] fix: invalid imports after merge --- src/pages/inbox/report/ReportActionsList.tsx | 103 +++++++++--------- .../report/useFollowActionBadgeTarget.ts | 10 +- 2 files changed, 60 insertions(+), 53 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 308cac7f1a35..8906e94cc3b8 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,44 +1,24 @@ -import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; - -import {LegendList} from '@legendapp/list/react-native'; -import {useRoute} from '@react-navigation/native'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; - -import type {ActionListRef} from './src/components/FlashList/types'; -import type {PlatformStackRouteProp} from './src/libs/Navigation/PlatformStackNavigation/types'; -import type {ReportsSplitNavigatorParamList} from './src/libs/Navigation/types'; -import type SCREENS from './src/SCREENS'; -import type * as OnyxTypes from './src/types/onyx'; - -import FloatingMessageCounter from './FloatingMessageCounter'; -import ReportActionIndexContext from './ReportActionIndexContext'; -import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; -import ReportActionsListHeader from './ReportActionsListHeader'; -import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; -import ReportActionsListPaddingView from './ReportActionsListPaddingView'; -import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; -import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; -import {renderScrollComponent as renderActionSheetAwareScrollView} from './src/components/ActionSheetAwareScrollView'; -import ReportActionsSkeletonView from './src/components/ReportActionsSkeletonView'; -import CONST from './src/CONST'; -import useEmitComposerScrollEvents from './src/hooks/useEmitComposerScrollEvents'; -import useEnvironment from './src/hooks/useEnvironment'; -import useLinkedMessageOfflineLoading from './src/hooks/useLinkedMessageOfflineLoading'; -import useLocalize from './src/hooks/useLocalize'; -import useMarkAsRead from './src/hooks/useMarkAsRead'; -import useNetwork from './src/hooks/useNetwork'; -import useOnyx from './src/hooks/useOnyx'; -import useReportActionsScroll from './src/hooks/useReportActionsScroll'; -import useResponsiveLayout from './src/hooks/useResponsiveLayout'; -import useThemeStyles from './src/hooks/useThemeStyles'; -import useUnreadMarker from './src/hooks/useUnreadMarker'; -import {isConsecutiveChronosAutomaticTimerAction} from './src/libs/ChronosUtils'; -import getNonEmptyStringOnyxID from './src/libs/getNonEmptyStringOnyxID'; -import isSearchTopmostFullScreenRoute from './src/libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import TransitionTracker from './src/libs/Navigation/TransitionTracker'; +import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; +import type {ActionListRef} from '@components/FlashList/types'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useEnvironment from '@hooks/useEnvironment'; +import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; +import useLocalize from '@hooks/useLocalize'; +import useMarkAsRead from '@hooks/useMarkAsRead'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useReportActionsScroll from '@hooks/useReportActionsScroll'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUnreadMarker from '@hooks/useUnreadMarker'; + +import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import { getFirstVisibleReportActionID, getReportActionHtml, @@ -48,7 +28,7 @@ import { isNewerReportAction, isReversedTransaction, isTransactionThread, -} from './src/libs/ReportActionsUtils'; +} from '@libs/ReportActionsUtils'; import { chatIncludesChronosWithID, isArchivedNonExpenseReport, @@ -60,13 +40,38 @@ import { isIOUReport, isTaskReport, shouldShowMarkAsDone, -} from './src/libs/ReportUtils'; -import markOpenReportEnd from './src/libs/telemetry/markOpenReportEnd'; -import ONYXKEYS from './src/ONYXKEYS'; -import {useActionListContext, useActionListRef} from './src/pages/inbox/ActionListContext'; -import {useConciergeDraft, useConciergeDraftActions} from './src/pages/inbox/ConciergeDraftContext'; -import {useConciergeSessionState} from './src/pages/inbox/ConciergeSessionContext'; -import {getStableReportSelector} from './src/selectors/Report'; +} from '@libs/ReportUtils'; +import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; + +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; + +import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; +import {useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; + +import CONST from '@src/CONST'; +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 type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {LegendList} from '@legendapp/list/react-native'; +import {useRoute} from '@react-navigation/native'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; + +import FloatingMessageCounter from './FloatingMessageCounter'; +import ReportActionIndexContext from './ReportActionIndexContext'; +import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; +import ReportActionsListHeader from './ReportActionsListHeader'; +import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; +import ReportActionsListPaddingView from './ReportActionsListPaddingView'; +import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; +import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; type ReportActionsListContentProps = { diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index 4d0ace1d7f82..67c952452920 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -1,10 +1,12 @@ -import {useEffect, useRef} from 'react'; +import usePrevious from '@hooks/usePrevious'; + +import Navigation from '@libs/Navigation/Navigation'; -import type * as OnyxTypes from './src/types/onyx'; +import type * as OnyxTypes from '@src/types/onyx'; + +import {useEffect, useRef} from 'react'; import shouldFollowActionBadgeTarget from './shouldFollowActionBadgeTarget'; -import usePrevious from './src/hooks/usePrevious'; -import Navigation from './src/libs/Navigation/Navigation'; type UseFollowActionBadgeTargetParams = { /** Whether the app is running in production, where this auto-scroll behavior is gated off */ From 0bdb3ce65c6c261f98dd8c36658c20c1e9d49496 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 18 Aug 2026 18:55:10 +0200 Subject: [PATCH 12/13] perf: optimize LegendList recycling --- .../LocalPDFReceiptPreview/index.tsx | 9 +-- .../ReceiptPDFOverlay/index.tsx | 6 +- .../inbox/report/ReportActionIndexContext.tsx | 17 +++++- src/pages/inbox/report/ReportActionItem.tsx | 21 ++++--- src/pages/inbox/report/ReportActionsList.tsx | 48 ++++++++++++++- tests/ui/ReportActionsListTest.tsx | 61 +++++++++++++++++++ 6 files changed, 145 insertions(+), 17 deletions(-) diff --git a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx index 27171c3e183d..84f9189259f4 100644 --- a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx +++ b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError'; import useThemeStyles from '@hooks/useThemeStyles'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; -import React, {useState} from 'react'; import {View} from 'react-native'; import type LocalPDFReceiptPreviewProps from './types'; @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true}; function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) { const styles = useThemeStyles(); - const [failedToLoad, setFailedToLoad] = useState(false); - const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined); - const [pageAspectRatio, setPageAspectRatio] = useState(undefined); + const [failedToLoad, setFailedToLoad] = useReportActionItemState(false); + const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined); + const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState(undefined); const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => { pdf.getPage(1) diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6e64cd2d4184..24c0014f920c 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import variables from '@styles/variables'; import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize'; @@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -52,7 +54,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure // Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh), // mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render. - const [failedURL, setFailedURL] = useState(null); + const [failedURL, setFailedURL] = useReportActionItemState(null); const hasFailed = failedURL !== null && failedURL === fileURL; // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 33dcc1060c6e..4cb8f9a04ecd 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,4 +1,7 @@ -import {createContext} from 'react'; +import type {Dispatch, SetStateAction} from 'react'; + +import {useRecyclingState} from '@legendapp/list/react-native'; +import {createContext, useContext, useState} from 'react'; /** * Carries an action item's position from the list renderer down to the rare consumers that @@ -11,8 +14,20 @@ import {createContext} from 'react'; type ReportActionPosition = { index: number; isNewest: boolean; + isRecycling?: boolean; }; const ReportActionIndexContext = createContext({index: 0, isNewest: false}); +/** + * Uses LegendList's recycling-aware state in the main report list and behaves like useState in shared, non-recycled lists. + */ +function useReportActionItemState(initialState: State | (() => State)): [State, Dispatch>] { + const {isRecycling = false} = useContext(ReportActionIndexContext); + const state = useState(initialState); + const recyclingState = useRecyclingState(initialState); + return isRecycling ? [...recyclingState] : state; +} + +export {useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index b9f5035180d9..b755594d3a2e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -79,12 +79,13 @@ import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useRecyclingEffect} from '@legendapp/list/react-native'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -95,6 +96,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import {useReportActionItemState} from './ReportActionIndexContext'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; @@ -215,16 +217,21 @@ function ReportActionItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); + const [isContextMenuActive, setIsContextMenuActive] = useReportActionItemState(() => isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useReportActionItemState(undefined); + const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useReportActionItemState(undefined); + const [isHidden, setIsHidden] = useReportActionItemState(false); const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); + useRecyclingEffect( + useCallback(() => { + downloadedPreviews.current = []; + }, []), + ); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); + const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); const shouldBreakGrouping = shouldBreakAccessibilityGrouping(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); @@ -368,7 +375,7 @@ function ReportActionItem({ return; } setIsHidden(false); - }, [latestDecision, action]); + }, [latestDecision, action, setIsHidden]); const toggleContextMenuFromActiveReportAction = () => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 8906e94cc3b8..cb1904c175bd 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -85,6 +85,47 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; const PAGINATION_THRESHOLD = 0.75; +const REPORT_ACTIONS_DRAW_DISTANCE = 500; + +const REPORT_ACTION_COMMENT_SIZE = { + SHORT: 'short', + MEDIUM: 'medium', + LONG: 'long', + EXTRA_LONG: 'extra-long', +} as const; + +function getReportActionCommentSize(messageLength: number): string { + if (messageLength <= 80) { + return REPORT_ACTION_COMMENT_SIZE.SHORT; + } + if (messageLength <= 320) { + return REPORT_ACTION_COMMENT_SIZE.MEDIUM; + } + if (messageLength <= 1200) { + return REPORT_ACTION_COMMENT_SIZE.LONG; + } + return REPORT_ACTION_COMMENT_SIZE.EXTRA_LONG; +} + +function getItemType(item: OnyxTypes.ReportAction): string { + if (item.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { + return item.actionName; + } + + const message = getReportActionMessage(item); + const commentSize = getReportActionCommentSize(message?.text.length ?? 0); + + if (item.isAttachmentOnly) { + return `${item.actionName}-attachment`; + } + if (item.isAttachmentWithText) { + return `${item.actionName}-attachment-${commentSize}`; + } + if (item.linkMetadata?.length) { + return `${item.actionName}-link-preview-${commentSize}`; + } + return `${item.actionName}-${commentSize}`; +} /** * Create a unique key for each action in the list. @@ -417,7 +458,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const reportActionIndex = renderedVisibleReportActions.length - index - 1; return ( - + item.actionName} + getItemType={getItemType} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} alignItemsAtEnd={!shouldBeAlignedToTop} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 3ca064006a24..91b7ccf97ea8 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -162,9 +162,12 @@ jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => nul type MockLegendListProps = { data?: OnyxTypes.ReportAction[]; + drawDistance?: number; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; maintainScrollAtEnd?: {animated: boolean}; maintainScrollAtEndThreshold?: number; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; onScroll?: (event: { @@ -364,6 +367,64 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(1); }); + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(500); + expect(listProps?.recycleItems).toBe(true); + }); + + it('groups comments by layout characteristics for measurement estimates', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const getItemType = getCapturedListProps()?.getItemType; + const comment = mockReportActions.at(1); + if (!comment) { + throw new Error('Expected comment report action fixture'); + } + + expect(getItemType?.(comment)).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-short`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'medium-comment', + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(600)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'extra-long-comment', + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1500)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'attachment', + isAttachmentOnly: true, + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-attachment`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'link-preview', + linkMetadata: [{url: 'https://example.com'}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); + }); + it('continues loading older pages from scroll events when LegendList does not report reaching the start', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue({ From 442ff835de60bd2cb293ace3cd824ce7a5445594 Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 18 Aug 2026 18:55:39 +0200 Subject: [PATCH 13/13] fix: reset stateful report actions on recycle --- src/pages/inbox/report/MoneyReportContentCreated.tsx | 1 + src/pages/inbox/report/ReportActionItemContentCreated.tsx | 1 + src/pages/inbox/report/actionContents/ActionContentRouter.tsx | 1 + src/pages/inbox/report/actionContents/ChatMessageContent.tsx | 1 + 4 files changed, 4 insertions(+) diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 42bba53109fd..87778f24d862 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -76,6 +76,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre {isEditingInline ? (