From df48eb761cf2f355604cb321759205ba1e3b998b Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 11 Aug 2026 11:48:36 +0200 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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 ? ( Date: Wed, 19 Aug 2026 14:14:42 +0200 Subject: [PATCH 14/39] deps: update LegendList --- 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 fdcc29d0ff27..d09b27d03bc9 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.3.5", + "@legendapp/list": "^3.3.7", "@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.3.5", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.5.tgz", - "integrity": "sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.7.tgz", + "integrity": "sha512-Z6LvHZwds4Vn38bZKOrWpvh3Nde6o4c/ciQ3qy57/kNXqzjVTTqMoZRO2SZ00TwDzM0vLDesNWRglEbKyYhVuA==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index 38a086d660e5..b77e16fd575b 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.3.5", + "@legendapp/list": "^3.3.7", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", From 349ba35566d34e6fc402d1a8614b5a6c5dc2480a Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 21 Aug 2026 18:53:23 +0200 Subject: [PATCH 15/39] fix: initial scroll --- src/hooks/useReportActionsScroll.ts | 8 -- src/pages/inbox/report/ReportActionsList.tsx | 28 ++++-- tests/ui/ReportActionsListTest.tsx | 96 ++++++++++++++++++- tests/unit/ReportActionsListThresholdTest.tsx | 30 +++--- tests/unit/useReportActionsScrollTest.tsx | 3 - 5 files changed, 125 insertions(+), 40 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index f478f4147627..6a4b7a9aee9b 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -57,8 +57,6 @@ type UseReportActionsScrollParams = { keyExtractor: (item: OnyxTypes.ReportAction) => string; /** Whether the user has scrolled past the "visible" threshold */ - hasScrolledOverThreshold: boolean; - /** Marks the newest action as read and clears any pending skipped mark-as-read */ markNewestActionAsRead: () => void; @@ -114,9 +112,6 @@ type UseReportActionsScrollResult = { /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; - /** Whether the list should preserve the currently visible content when data changes */ - shouldMaintainVisibleContentPosition: boolean; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; @@ -135,7 +130,6 @@ function useReportActionsScroll({ sortedVisibleReportActions, renderedVisibleReportActions, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, @@ -184,7 +178,6 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey; - const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = @@ -366,7 +359,6 @@ function useReportActionsScroll({ scrollToActionBadgeTarget, flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldMaintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index cb1904c175bd..1b2a230910db 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -198,8 +198,14 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent 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. - const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting + // gives the complete dataset a fresh initial layout so initialScrollAtEnd targets its actual end. + const listID = [ + reportID, + reportActionIDFromRoute, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const isReportArchived = !!isArchivedReport(reportNameValuePairs); @@ -308,9 +314,21 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return visibleReportActionsWithDraft; })(); + const [initialReportActionsSnapshot, setInitialReportActionsSnapshot] = useState<{reportActions: OnyxTypes.ReportAction[]; reportID: string}>(); + const hasInitialReportActionsSnapshot = initialReportActionsSnapshot?.reportID === reportID; + + // OpenReport starts with a tiny cached page before replacing it with the hydrated page. Keep that + // already-visible page mounted until hydration finishes instead of exposing intermediate estimated + // layouts. The hydrated list then mounts from scratch using the full dataset. + if (!hasOnceLoadedReportActions && !hasInitialReportActionsSnapshot && renderedVisibleReportActions.length > 0) { + setInitialReportActionsSnapshot({reportActions: renderedVisibleReportActions, reportID}); + } + + const reportActionsToRender = !hasOnceLoadedReportActions && hasInitialReportActionsSnapshot ? initialReportActionsSnapshot.reportActions : renderedVisibleReportActions; + // 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 listData = reportActionsToRender.toReversed(); const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; @@ -349,7 +367,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, - shouldMaintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -359,7 +376,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent sortedVisibleReportActions, renderedVisibleReportActions: listData, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, @@ -590,7 +606,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent maintainScrollAtEnd={{animated: false}} // Keyboard avoidance can shrink the viewport by almost a full screen before LegendList evaluates end proximity. maintainScrollAtEndThreshold={1} - maintainVisibleContentPosition={shouldMaintainVisibleContentPosition ? {data: true} : false} + maintainVisibleContentPosition={{data: true}} onLoad={onLoad} onContentSizeChange={() => { trackVerticalScrolling(undefined); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 91b7ccf97ea8..1f1fb18ed227 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -132,7 +132,22 @@ const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest. // 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('@legendapp/list/react-native', () => ({LegendList: jest.fn(() => null)})); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); +jest.mock('@legendapp/list/react-native', () => { + const reactModule = jest.requireActual('react'); + return { + LegendList: jest.fn(() => { + reactModule.useEffect(() => { + mockLegendListMount(); + return () => { + mockLegendListUnmount(); + }; + }, []); + return 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', () => @@ -148,7 +163,6 @@ jest.mock('@hooks/useReportActionsScroll', () => shouldBeAlignedToTop: false, initialScrollIndex: undefined, initialScrollIndexParams: undefined, - shouldMaintainVisibleContentPosition: false, onLoad: jest.fn(), })), ); @@ -161,11 +175,13 @@ jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => nul jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); type MockLegendListProps = { + alignItemsAtEnd?: boolean; data?: OnyxTypes.ReportAction[]; drawDistance?: number; extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; - maintainScrollAtEnd?: {animated: boolean}; + initialScrollAtEnd?: boolean; + maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; @@ -208,6 +224,7 @@ const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAct const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); +let mockHasOnceLoadedReportActions = true; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -277,6 +294,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockHasOnceLoadedReportActions = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -339,7 +357,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -367,6 +385,76 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(1); }); + it('initially aligns the seed page to the end', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.alignItemsAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('remounts the list when the initial report actions finish hydrating', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + + mockHasOnceLoadedReportActions = true; + // The mocked Onyx hook does not own state, so changing its return value cannot schedule the + // rerender that the real Onyx subscription causes. Change a prop to trigger that render. + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('keeps the initial actions visible until the hydrated page is complete', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(false); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length + 1); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); + }); + it('limits the render buffer and enables item recycling', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 849ef4c9a058..86495ca6c004 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -42,17 +42,14 @@ type CapturedListProps = { onScroll?: (event: ScrollEvent) => void; }; -// 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. +// Capture the props the list is rendered with so we can verify data anchoring remains enabled while the +// list crosses the visible-action threshold. let capturedListProps: CapturedListProps = {}; -// 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 = []; -// Whether the captured LegendList configuration enables maintain-visible-content-position. +// Whether the captured LegendList configuration enables data-based maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config === undefined ? undefined : config !== false; + return typeof config === 'object' && config.data; } jest.mock('@legendapp/list/react-native', () => { @@ -62,7 +59,6 @@ jest.mock('@legendapp/list/react-native', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition === undefined ? undefined : props.maintainVisibleContentPosition !== false); return null; }), }; @@ -135,7 +131,6 @@ async function renderList(initialOffset: number) { beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -160,25 +155,22 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList hasScrolledOverThreshold', () => { - it('enables maintainVisibleContentPosition on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList maintainVisibleContentPosition', () => { + it('enables data anchoring on first render when mounted while scrolled past the threshold', async () => { await renderList(THRESHOLD + 50); - // Must be true on the FIRST render, not merely after an effect settles — deferring this to an effect - // would let the mount-time mark-as-read path observe a wrong `isScrolledToEnd`. - expect(mockMvcpHistory.at(0)).toBe(true); expect(isMvcpEnabled()).toBe(true); }); - it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + it('enables data anchoring at the bottom so initial hydration preserves the visible tail', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); - it('flips the flag as the user scrolls across the threshold', async () => { + it('keeps data anchoring enabled as the user scrolls across the threshold', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); act(() => { capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); @@ -188,6 +180,6 @@ describe('ReportActionsList hasScrolledOverThreshold', () => { act(() => { capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); }); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 287cd5adcd86..ba9564ef2c36 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -180,7 +180,6 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, @@ -252,7 +251,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldMaintainVisibleContentPosition).toBe(false); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -261,7 +259,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldMaintainVisibleContentPosition).toBe(true); }); it('is aligned to top for a money request report', async () => { From d91dc25104348195c612e6a7d1980ff805a4ab88 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 24 Aug 2026 22:21:10 +0200 Subject: [PATCH 16/39] fix: remove `useCallback` --- src/pages/inbox/report/ReportActionItem.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index b755594d3a2e..337e93b91f10 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -85,7 +85,7 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useCallback, useContext, useEffect, useRef} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -225,11 +225,9 @@ function ReportActionItem({ const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); - useRecyclingEffect( - useCallback(() => { - downloadedPreviews.current = []; - }, []), - ); + useRecyclingEffect(() => { + downloadedPreviews.current = []; + }); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); From 35fab427feafc3465ffae0cd668e9fb3fa831705 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 27 Aug 2026 23:14:58 +0200 Subject: [PATCH 17/39] fix: invalid initial scroll offset with LegendList --- src/hooks/useReportActionsScroll.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 6a4b7a9aee9b..91874c395c22 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -345,7 +345,7 @@ function useReportActionsScroll({ let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; if (targetIndex >= 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { initialScrollIndex = 0; } From b5dbfab1482562b8209735058a6bd075624be837 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 31 Aug 2026 12:54:12 +0200 Subject: [PATCH 18/39] chore: update LegendList --- 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 653460e36fd0..5f6309c5478c 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.3.7", + "@legendapp/list": "^3.3.9", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11399,9 +11399,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.7.tgz", - "integrity": "sha512-Z6LvHZwds4Vn38bZKOrWpvh3Nde6o4c/ciQ3qy57/kNXqzjVTTqMoZRO2SZ00TwDzM0vLDesNWRglEbKyYhVuA==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.9.tgz", + "integrity": "sha512-ETxJPA5m59+AF65r9Ko25WMtV1KAWEebYeG0sIOTBtuZxfNpX8rBsLhFDQ8qY1tbPhJsr+U025u0u6irE9z8zw==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index db4a633650b9..9d9660780d2c 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.3.7", + "@legendapp/list": "^3.3.9", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", From 33d5ab00707b9dd8d9204c4707719f27a705f28a Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 31 Aug 2026 15:04:58 +0200 Subject: [PATCH 19/39] fix: preserve LegendList anchors in Android MVCP Keep positioned zero-sized anchors eligible for native scroll compensation when LegendList corrects estimated item sizes. --- patches/react-native/details.md | 3 ++- .../react-native+0.86.0+002+fixMVCPAndroid.patch | 12 ++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 4395fa022dbb..7b69f3793020 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -9,9 +9,10 @@ ### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch) -- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements: +- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch: 1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates 2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position + 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink. - Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch index 223cce3db9b3..90b705c6df69 100644 --- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch +++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644 for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) -@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper( +@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height ++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor. ++ // Preserve it instead of choosing the container that holds all rendered items. ++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) { ++ firstVisibleView = child ++ break ++ } ++ // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644 } + val frame = Rect() + firstVisibleView.getHitRect(frame) -+ if (frame.width() > 0 || frame.height() > 0) { ++ // Zero-sized anchors have a meaningful position even though they have no area. ++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) { + prevFirstVisibleFrame = frame + } else { + prevFirstVisibleFrame = null From df4591fc230a9bad7df7c50f0797cb308397c727 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 31 Aug 2026 15:05:37 +0200 Subject: [PATCH 20/39] fix: let LegendList own report action scroll anchoring Enable native data and size anchoring, limit end-following to the latest page near its bottom, and remove competing message and draft scroll effects. Consume explicit live-tail requests after rendering instead of on a future viewport layout. Keep the configured 1500px render window, correct the action-badge offset, and add regression coverage for scroll ownership and end-following. --- src/hooks/useReportActionsScroll.ts | 53 ++++--------------- src/pages/inbox/report/ReportActionsList.tsx | 22 +++----- .../useReportActionsNewActionLiveTail.ts | 9 ++-- tests/ui/ReportActionsListTest.tsx | 26 +++++++-- .../useReportActionsNewActionLiveTailTest.ts | 25 ++++++++- tests/unit/useReportActionsScrollTest.tsx | 52 +++++++++--------- 6 files changed, 90 insertions(+), 97 deletions(-) diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 07c38a7a85a4..cf7e6f65f246 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -32,7 +32,6 @@ import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; -import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; type UseReportActionsScrollParams = { /** The Concierge chat report */ @@ -75,9 +74,6 @@ type UseReportActionsScrollParams = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; - /** Stable key that changes when a streamed concierge draft becomes visible, used to trigger autoscroll */ - draftAutoScrollKey: string; - /** The index of the action badge target in the rendered actions list (-1 if none) */ actionBadgeTargetIndex: number; @@ -109,9 +105,6 @@ type UseReportActionsScrollResult = { /** Scrolls to the action badge target */ scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ - flushPendingScrollToBottom: () => void; - /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; @@ -139,7 +132,6 @@ function useReportActionsScroll({ unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -222,34 +214,17 @@ function useReportActionsScroll({ reportLoadingState, }); - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${reportID}:${linkedReportActionID}`, - }); - - const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - + // Consume an explicit live-tail request after the data render, not on an unrelated future + // viewport layout (for example, opening the keyboard after the user has scrolled away). + // Incoming messages and streaming draft growth are followed by LegendList itself. useEffect(() => { - if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { - return; - } - - if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (!isScrollToBottomEnabled) { return; } - - setIsFloatingMessageCounterVisible(false); - requestAnimationFrame(() => { - reportScrollManager.scrollToBottom(); - }); - }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // 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 @@ -317,16 +292,7 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); - }; - - const flushPendingScrollToBottom = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(false); - completeLiveTailPruneAfterScrollToBottom(); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; // Data is ready when LegendList finishes its first render. @@ -362,7 +328,6 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index f0a84591f444..97a0cd1f97f7 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -88,7 +88,7 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; const PAGINATION_THRESHOLD = 0.75; -const REPORT_ACTIONS_DRAW_DISTANCE = 500; +const REPORT_ACTIONS_DRAW_DISTANCE = 1500; const REPORT_ACTION_COMMENT_SIZE = { SHORT: 'short', @@ -336,7 +336,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { @@ -366,7 +365,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, initialScrollIndex, initialScrollIndexParams, @@ -385,7 +383,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct unreadMarkerReportActionID, unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, @@ -593,10 +590,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ListFooterComponent={listHeaderComponent} ListFooterComponentStyle={shouldBeAlignedToTop ? styles.flex1 : undefined} keyboardShouldPersistTaps="handled" - onLayout={(event) => { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} + onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} @@ -605,14 +599,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} alignItemsAtEnd={!shouldBeAlignedToTop} - maintainScrollAtEnd={{animated: false}} + // Only follow the real latest page. Older/linked windows must retain their visible anchor. + maintainScrollAtEnd={!hasNewerActions && {animated: false}} // Keyboard avoidance can shrink the viewport by almost a full screen before LegendList evaluates end proximity. - maintainScrollAtEndThreshold={1} - maintainVisibleContentPosition={{data: true}} + // Leave the end-follow region as soon as the user starts reading older messages. + maintainScrollAtEndThreshold={0.01} + maintainVisibleContentPosition onLoad={onLoad} - onContentSizeChange={() => { - trackVerticalScrolling(undefined); - }} + onContentSizeChange={() => trackVerticalScrolling(undefined)} /> diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index be9abd02bd40..73e91978b858 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -60,9 +60,8 @@ type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune' /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the - * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it - * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke - * it from list `onLayout` outside this hook. + * deferred scroll + pagination prune after the data render. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The prune callback completes the explicit scroll request in the caller. */ function useReportActionsNewActionLiveTail({ conciergeChat, @@ -142,10 +141,8 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(true); } - - setIsScrollToBottomEnabled(true); }, }); }); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index fb6b55c9beb4..c9c51c1f0d34 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -159,7 +159,6 @@ jest.mock('@hooks/useReportActionsScroll', () => isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, initialScrollIndex: undefined, initialScrollIndexParams: undefined, @@ -183,6 +182,7 @@ type MockLegendListProps = { initialScrollAtEnd?: boolean; maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: boolean; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -382,12 +382,13 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); - it('keeps the latest messages anchored when the keyboard changes the list layout', () => { + it('delegates end following and size corrections to LegendList without a full-viewport threshold', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); - expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(1); + expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); }); it('initially aligns the seed page to the end', () => { @@ -400,6 +401,19 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); }); + it('does not follow the end of a page that still has newer actions to load', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + it('remounts the list when the initial report actions finish hydrating', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; @@ -414,6 +428,7 @@ describe('ReportActionsList (body)', () => { view.rerender( , ); @@ -439,6 +454,7 @@ describe('ReportActionsList (body)', () => { view.rerender( , ); @@ -451,6 +467,7 @@ describe('ReportActionsList (body)', () => { view.rerender( , ); @@ -466,7 +483,7 @@ describe('ReportActionsList (body)', () => { const listProps = getCapturedListProps(); - expect(listProps?.drawDistance).toBe(500); + expect(listProps?.drawDistance).toBe(1500); expect(listProps?.recycleItems).toBe(true); }); @@ -555,6 +572,7 @@ describe('ReportActionsList (body)', () => { view.rerender( , ); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index f95775bd5e36..8ba70c1a08b6 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -99,7 +99,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -118,6 +118,29 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it('requests one post-render scroll for a sent comment instead of also scrolling immediately', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not queue a bottom scroll that would compete with a report-preview target', () => { + const preview = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW}); + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [preview]}))); + + act(() => { + newActionHandler?.(true, preview); + }); + + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('threads the conciergeChat report through to the catch-up openReport call', () => { const conciergeChat = {reportID: 'concierge-live-tail-1'}; renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({conciergeChat})}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index c5ae4238936a..4f344427e109 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -75,12 +75,6 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ }), })); -// --- useScrollToEndOnNewMessageReceived --- -jest.mock('@hooks/useScrollToEndOnNewMessageReceived', () => ({ - __esModule: true, - default: jest.fn(), -})); - // --- TransitionTracker --- const mockTransitionCallbacks: Array<() => void> = []; jest.mock('@libs/Navigation/TransitionTracker', () => ({ @@ -186,7 +180,6 @@ function buildParams(overrides: Partial = {}): ScrollParams { unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -233,6 +226,9 @@ describe('useReportActionsScroll', () => { mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -285,7 +281,7 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); expect(result.current.initialScrollIndex).toBe(0); - expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('positions an unread marker at chronological index zero', async () => { @@ -371,57 +367,57 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: -CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); - describe('flushPendingScrollToBottom', () => { + describe('pending live-tail requests', () => { it('does nothing when scroll-to-bottom is not enabled', async () => { mockIsScrollToBottomEnabled = false; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + await renderScroll(); expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); }); - it('scrolls, disables itself and prunes when scroll-to-bottom is enabled', async () => { + it('consumes the request after render without waiting for a future viewport layout', async () => { mockIsScrollToBottomEnabled = true; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + const {rerender} = await renderScroll(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); + + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); }); describe('effects', () => { - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('leaves incoming-message following to LegendList', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const {rerender} = await renderScroll(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); }); - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; + it('does not schedule a competing scroll when a streamed draft grows', async () => { + mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const draft = makeAction('2', {message: [{type: 'COMMENT', text: 'Hello', html: '

Hello

'}]}); + const {rerender} = await renderScroll({renderedVisibleReportActions: [makeAction('1'), draft]}); mockScrollToBottom.mockClear(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const updatedDraft: ReportAction = {...draft, message: [{type: 'COMMENT', text: 'Hello, here is the rest of the reply.', html: '

Hello, here is the rest of the reply.

'}]}; + rerender(buildParams({renderedVisibleReportActions: [makeAction('1'), updatedDraft]})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); From 5eafbcd24983a7ef5c35e26d2be883e6eb9376ec Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 25 Aug 2026 16:25:02 +0200 Subject: [PATCH 21/39] feat: show loading skeleton instead of blank page --- src/pages/inbox/report/ReportActionsList.tsx | 19 +++++++++- tests/ui/ReportActionsListTest.tsx | 38 +++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 97a0cd1f97f7..e4d7c01596e2 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -63,6 +63,7 @@ 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 {View} from 'react-native'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionIndexContext from './ReportActionIndexContext'; @@ -389,6 +390,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setTreatAsNoPaginationAnchor, }); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + const shouldShowInitialViewportSkeleton = loadedInitialViewportListID !== listID; + + const handleListLoad = () => { + onLoad(); + setLoadedInitialViewportListID(listID); + }; + const loadOlderChatsOnStartReached = () => { if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; @@ -605,9 +614,17 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // Leave the end-follow region as soon as the user starts reading older messages. maintainScrollAtEndThreshold={0.01} maintainVisibleContentPosition - onLoad={onLoad} + onLoad={handleListLoad} onContentSizeChange={() => trackVerticalScrolling(undefined)} /> + {shouldShowInitialViewportSkeleton && ( + + + + )} ); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index c9c51c1f0d34..92ece79f3669 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -134,12 +134,16 @@ const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest. // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. const mockLegendListMount = jest.fn(); const mockLegendListUnmount = jest.fn(); +let mockShouldCallLegendListOnLoad = true; jest.mock('@legendapp/list/react-native', () => { const reactModule = jest.requireActual('react'); return { - LegendList: jest.fn(() => { + LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => { reactModule.useEffect(() => { mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } return () => { mockLegendListUnmount(); }; @@ -183,6 +187,7 @@ type MockLegendListProps = { maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + onLoad?: () => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -300,6 +305,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -440,6 +446,36 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); }); + it('covers each initial viewport until LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + it('keeps the initial actions visible until the hydrated page is complete', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; From 90491ebb2e43efc341517e459ae9d4f48117d60f Mon Sep 17 00:00:00 2001 From: chrispader Date: Tue, 25 Aug 2026 16:42:39 +0200 Subject: [PATCH 22/39] fix: show loading skeleton until fully rendered --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- tests/ui/ReportActionsListTest.tsx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index e4d7c01596e2..398b33c356c6 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -391,7 +391,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }); const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); - const shouldShowInitialViewportSkeleton = loadedInitialViewportListID !== listID; + const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); const handleListLoad = () => { onLoad(); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 92ece79f3669..5460a0d1e8aa 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -446,7 +446,7 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); }); - it('covers each initial viewport until LegendList finishes rendering it', async () => { + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; mockShouldCallLegendListOnLoad = false; @@ -457,7 +457,7 @@ describe('ReportActionsList (body)', () => { act(() => { getCapturedListProps()?.onLoad?.(); }); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); mockHasOnceLoadedReportActions = true; view.rerender( @@ -1253,9 +1253,10 @@ describe('ReportActionsList (body)', () => { expect(mockStartSession).toHaveBeenCalled(); }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { + it('should cover cached actions until the refreshed report finishes hydrating', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. setupMainDMConciergeMocks(SESSION_START, false, false); mockUsePaginatedReportActions.mockReturnValue({ @@ -1266,7 +1267,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockLegendList).toHaveBeenCalled(); }); From e25e07754bdb6e3fda510764311a7ba78f0065a3 Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 31 Aug 2026 15:05:37 +0200 Subject: [PATCH 23/39] fix: let LegendList own report action scroll anchoring Enable native data and size anchoring, limit end-following to the latest page near its bottom, and remove competing message and draft scroll effects. Consume explicit live-tail requests after rendering instead of on a future viewport layout. Keep the configured 1500px render window, correct the action-badge offset, and add regression coverage for scroll ownership and end-following. --- src/pages/inbox/report/ReportActionsList.tsx | 1 - tests/ui/ReportActionsListTest.tsx | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 398b33c356c6..2750bd786604 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -610,7 +610,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct alignItemsAtEnd={!shouldBeAlignedToTop} // Only follow the real latest page. Older/linked windows must retain their visible anchor. maintainScrollAtEnd={!hasNewerActions && {animated: false}} - // Keyboard avoidance can shrink the viewport by almost a full screen before LegendList evaluates end proximity. // Leave the end-follow region as soon as the user starts reading older messages. maintainScrollAtEndThreshold={0.01} maintainVisibleContentPosition diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 5460a0d1e8aa..60eed393ac14 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -463,6 +463,7 @@ describe('ReportActionsList (body)', () => { view.rerender( , ); From b43c13abc2e91857ad7d4c0a115e1b26d772163c Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 3 Sep 2026 15:42:53 +0200 Subject: [PATCH 24/39] fix: skeleton view alignment --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 2750bd786604..933ae9747913 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -619,7 +619,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct {shouldShowInitialViewportSkeleton && ( From d63a4bbb1ecf6bf341764385e22a5363c4f1b5c4 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 11:49:35 +0200 Subject: [PATCH 25/39] fix: lint --- jest/setup.ts | 29 +++++++++++-------- .../MoneyRequestReportActionsList.tsx | 6 +++- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 0334bb2bceeb..2169784ec32e 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,21 +1,26 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@shopify/flash-list/jestSetup'; +import type LegendListModuleType from '@legendapp/list/react-native'; +import type React from 'react'; import type {ReactNode} from 'react'; +import type {FlatList, FlatListProps, NativeSyntheticEvent, NativeScrollEvent} from 'react-native'; +import type ReactNativeModuleType from 'react-native'; import type * as RNAppLogs from 'react-native-app-logs'; import type {ReadDirItem} from 'react-native-fs'; -import type * as RNKeyboardController from 'react-native-keyboard-controller'; import 'react-native-gesture-handler/jestSetup'; +import type * as RNKeyboardController from 'react-native-keyboard-controller'; import type Animated from 'react-native-reanimated'; -import {useMemo} from 'react'; import 'setimmediate'; +import {useMemo} from 'react'; import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; -import {TextDecoder, TextEncoder} from 'util'; import '@src/polyfills/PromiseWithResolvers'; import '@src/polyfills/requestIdleCallback'; +import {TextDecoder, TextEncoder} from 'util'; + import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; @@ -31,23 +36,23 @@ 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'); + const ReactActual = jest.requireActual('react'); + const FlatListActual = jest.requireActual('react-native').FlatList; + const LegendListModuleActual = jest.requireActual('@legendapp/list/react-native').LegendList; - type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & { + 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; + onScroll?: (event: NativeSyntheticEvent) => void; }; return { - ...LegendListActual, - LegendList: ReactActual.forwardRef, MockLegendListProps>( + ...LegendListModuleActual, + LegendList: ReactActual.forwardRef, MockLegendListProps>( ( { alignItemsAtEnd, @@ -63,7 +68,7 @@ jest.mock('@legendapp/list/react-native', () => { }, ref, ) => { - const handleScroll = (event: import('react-native').NativeSyntheticEvent) => { + const handleScroll = (event: NativeSyntheticEvent) => { onScroll?.(event); const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y; @@ -72,7 +77,7 @@ jest.mock('@legendapp/list/react-native', () => { } }; - return ReactActual.createElement(FlatList, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref}); + return ReactActual.createElement(FlatListActual, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref}); }, ), }; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 789c4f1873bb..6e491a2fd1c7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -639,8 +639,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + // This value cannot be memoized, because it is based on the indexWithinReportActions which changes on every render. + // eslint-disable-next-line react/jsx-no-constructed-context-values + const reportActionIndexContextValue = {index: indexWithinReportActions, isNewest: indexWithinReportActions === visibleReportActions.length - 1}; + return ( - + Date: Wed, 9 Sep 2026 11:50:28 +0200 Subject: [PATCH 26/39] fix: TS errors --- tests/unit/useReportActionsScrollTest.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 7522965834c8..976d23532308 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -319,7 +319,6 @@ describe('useReportActionsScroll', () => { // Still aligned to top so short reports keep their layout, but the mount position is the latest message. expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); expect(result.current.initialScrollIndex).toBeUndefined(); }); @@ -330,7 +329,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); }); }); From 674ae3eb7084106f9e3051096cc28f3c2c001c2f Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 12:13:29 +0200 Subject: [PATCH 27/39] deps: update LegendList --- 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 c6d60499e64c..9f281864e26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.3.9", + "@legendapp/list": "^3.3.10", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11409,9 +11409,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.9.tgz", - "integrity": "sha512-ETxJPA5m59+AF65r9Ko25WMtV1KAWEebYeG0sIOTBtuZxfNpX8rBsLhFDQ8qY1tbPhJsr+U025u0u6irE9z8zw==", + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.10.tgz", + "integrity": "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index 054f464d9488..526863340136 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.3.9", + "@legendapp/list": "^3.3.10", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", From bddc07c060be8f3f3d2189b3588a10d49231e769 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 12:18:55 +0200 Subject: [PATCH 28/39] revert: Fullstory ref wrapping tests --- tests/unit/FullstoryTest.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/unit/FullstoryTest.ts b/tests/unit/FullstoryTest.ts index 9737920cf9f2..4874ce9705e0 100644 --- a/tests/unit/FullstoryTest.ts +++ b/tests/unit/FullstoryTest.ts @@ -4,20 +4,10 @@ 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 396fcf569e7ffa479fbd35d7feb9bd918a548f7f Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 12:24:24 +0200 Subject: [PATCH 29/39] test: fix LegendList Jest regressions --- jest/setup.ts | 4 ++-- tests/unit/ReportActionsListThresholdTest.tsx | 2 +- tests/unit/useReportActionsScrollTest.tsx | 10 ---------- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 2169784ec32e..01cbf082232f 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,7 +1,7 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@shopify/flash-list/jestSetup'; -import type LegendListModuleType from '@legendapp/list/react-native'; +import type * as LegendListModule from '@legendapp/list/react-native'; import type React from 'react'; import type {ReactNode} from 'react'; import type {FlatList, FlatListProps, NativeSyntheticEvent, NativeScrollEvent} from 'react-native'; @@ -38,7 +38,7 @@ mockFSLibrary(); jest.mock('@legendapp/list/react-native', () => { const ReactActual = jest.requireActual('react'); const FlatListActual = jest.requireActual('react-native').FlatList; - const LegendListModuleActual = jest.requireActual('@legendapp/list/react-native').LegendList; + const LegendListModuleActual = jest.requireActual('@legendapp/list/react-native'); type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & { alignItemsAtEnd?: boolean; diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 0eff736fd0ea..61c2508361be 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -49,7 +49,7 @@ let capturedListProps: CapturedListProps = {}; // Whether the captured LegendList configuration enables data-based maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return typeof config === 'object' && config.data; + return config === true || (typeof config === 'object' && config.data); } jest.mock('@legendapp/list/react-native', () => { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 976d23532308..118361255128 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -433,16 +433,6 @@ describe('useReportActionsScroll', () => { expect(mockScrollToBottom).not.toHaveBeenCalled(); }); - it('scrolls to bottom on mount for a single-expense money request report opened from the X Replies link', async () => { - mockIsMoneyRequestReport = true; - mockRouteParams = {shouldScrollToLatest: 'true'}; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - it('clears the X Replies flag once it has been applied', async () => { mockIsMoneyRequestReport = true; mockRouteParams = {shouldScrollToLatest: 'true'}; From db709a1cf743b04cf8a92712ff2dbb0189c9ac98 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 12:46:01 +0200 Subject: [PATCH 30/39] fix: add proper LegendList mock for reassure --- jest/setup.ts | 48 ++++++++++++++++--------------- tests/unit/LegendListMockTest.tsx | 37 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 23 deletions(-) create mode 100644 tests/unit/LegendListMockTest.tsx diff --git a/jest/setup.ts b/jest/setup.ts index 01cbf082232f..7fb259a0c5d6 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,10 +2,9 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@shopify/flash-list/jestSetup'; import type * as LegendListModule from '@legendapp/list/react-native'; +import type * as FlashListModule from '@shopify/flash-list'; import type React from 'react'; import type {ReactNode} from 'react'; -import type {FlatList, FlatListProps, NativeSyntheticEvent, NativeScrollEvent} from 'react-native'; -import type ReactNativeModuleType from 'react-native'; import type * as RNAppLogs from 'react-native-app-logs'; import type {ReadDirItem} from 'react-native-fs'; @@ -33,26 +32,27 @@ 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. +// LegendList relies on native layout measurements that Jest does not produce. FlashList's Jest setup supplies +// deterministic layouts while keeping performance tests virtualized in the same way as the previous list. jest.mock('@legendapp/list/react-native', () => { const ReactActual = jest.requireActual('react'); - const FlatListActual = jest.requireActual('react-native').FlatList; + const FlashListActual = jest.requireActual('@shopify/flash-list').FlashList; const LegendListModuleActual = jest.requireActual('@legendapp/list/react-native'); - type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & { + type MockLegendListProps = Omit, 'data' | 'initialScrollIndex' | 'initialScrollIndexParams' | 'maintainVisibleContentPosition'> & { alignItemsAtEnd?: boolean; - data?: ArrayLike; + data?: readonly unknown[]; initialScrollAtEnd?: boolean; - initialScrollIndex?: number | {index: number}; + initialScrollIndex?: number | {index: number; viewOffset?: number; viewPosition?: number}; maintainScrollAtEnd?: unknown; + maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: unknown; - onScroll?: (event: NativeSyntheticEvent) => void; + recycleItems?: boolean; }; return { ...LegendListModuleActual, - LegendList: ReactActual.forwardRef, MockLegendListProps>( + LegendList: ReactActual.forwardRef, MockLegendListProps>( ( { alignItemsAtEnd, @@ -60,24 +60,26 @@ jest.mock('@legendapp/list/react-native', () => { initialScrollAtEnd, initialScrollIndex, maintainScrollAtEnd, + maintainScrollAtEndThreshold, maintainVisibleContentPosition, - onEndReached, - onEndReachedThreshold = 0, - onScroll, + recycleItems, ...props }, ref, ) => { - const handleScroll = (event: 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(FlatListActual, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref}); + const initialScrollConfig = typeof initialScrollIndex === 'number' ? {index: initialScrollIndex} : initialScrollIndex; + const flashListInitialScrollIndex = initialScrollAtEnd && data.length > 0 ? data.length - 1 : initialScrollConfig?.index; + const flashListInitialScrollIndexParams = initialScrollAtEnd + ? {viewPosition: 1} + : initialScrollConfig && {viewOffset: initialScrollConfig.viewOffset, viewPosition: initialScrollConfig.viewPosition}; + + return ReactActual.createElement(FlashListActual, { + ...props, + data, + initialScrollIndex: flashListInitialScrollIndex, + initialScrollIndexParams: flashListInitialScrollIndexParams, + ref, + }); }, ), }; diff --git a/tests/unit/LegendListMockTest.tsx b/tests/unit/LegendListMockTest.tsx new file mode 100644 index 000000000000..c0772ae8c644 --- /dev/null +++ b/tests/unit/LegendListMockTest.tsx @@ -0,0 +1,37 @@ +import {render} from '@testing-library/react-native'; + +import {LegendList} from '@legendapp/list/react-native'; +import {FlashList} from '@shopify/flash-list'; + +const DATA = ['first', 'second', 'third']; + +function renderItem() { + return null; +} + +describe('LegendList Jest mock', () => { + it('uses FlashList as its virtualized renderer', () => { + const renderResult = render( + , + ); + + expect(renderResult.UNSAFE_getByType(FlashList).props.data).toBe(DATA); + }); + + it('starts at the final item when initialScrollAtEnd is enabled', () => { + const renderResult = render( + , + ); + const flashList = renderResult.UNSAFE_getByType(FlashList); + + expect(flashList.props.initialScrollIndex).toBe(DATA.length - 1); + expect(flashList.props.initialScrollIndexParams).toEqual({viewPosition: 1}); + }); +}); From a9cdd7f38ef24bfbf2ac7d4c6e08844c91d5eff4 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 13:06:22 +0200 Subject: [PATCH 31/39] test: prevent duplicate LegendList pagination requests --- jest/setup.ts | 12 ++++++++++++ tests/unit/LegendListMockTest.tsx | 29 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/jest/setup.ts b/jest/setup.ts index 7fb259a0c5d6..28caafec6299 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -62,6 +62,9 @@ jest.mock('@legendapp/list/react-native', () => { maintainScrollAtEnd, maintainScrollAtEndThreshold, maintainVisibleContentPosition, + onEndReached, + onEndReachedThreshold = 0, + onScroll, recycleItems, ...props }, @@ -72,12 +75,21 @@ jest.mock('@legendapp/list/react-native', () => { const flashListInitialScrollIndexParams = initialScrollAtEnd ? {viewPosition: 1} : initialScrollConfig && {viewOffset: initialScrollConfig.viewOffset, viewPosition: initialScrollConfig.viewPosition}; + const handleScroll: NonNullable['onScroll']> = (event) => { + onScroll?.(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y; + if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) { + onEndReached?.(); + } + }; return ReactActual.createElement(FlashListActual, { ...props, data, initialScrollIndex: flashListInitialScrollIndex, initialScrollIndexParams: flashListInitialScrollIndexParams, + onScroll: handleScroll, ref, }); }, diff --git a/tests/unit/LegendListMockTest.tsx b/tests/unit/LegendListMockTest.tsx index c0772ae8c644..bcf57399e1c2 100644 --- a/tests/unit/LegendListMockTest.tsx +++ b/tests/unit/LegendListMockTest.tsx @@ -1,4 +1,4 @@ -import {render} from '@testing-library/react-native'; +import {fireEvent, render, screen} from '@testing-library/react-native'; import {LegendList} from '@legendapp/list/react-native'; import {FlashList} from '@shopify/flash-list'; @@ -34,4 +34,31 @@ describe('LegendList Jest mock', () => { expect(flashList.props.initialScrollIndex).toBe(DATA.length - 1); expect(flashList.props.initialScrollIndexParams).toEqual({viewPosition: 1}); }); + + it('invokes onEndReached only in response to a matching scroll event', () => { + const onEndReached = jest.fn(); + const renderResult = render( + , + ); + const flashList = renderResult.UNSAFE_getByType(FlashList); + + expect(flashList.props.onEndReached).toBeUndefined(); + expect(onEndReached).not.toHaveBeenCalled(); + + fireEvent.scroll(screen.getByTestId('legend-list'), { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {height: 600, width: 300}, + layoutMeasurement: {height: 400, width: 300}, + }, + }); + + expect(onEndReached).toHaveBeenCalledTimes(1); + }); }); From 347f283dbd89aee03cc4da2c8283c04f81f30fa6 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 14:03:14 +0200 Subject: [PATCH 32/39] feat: add skeleton pages for chat pagination --- jest/setup.ts | 30 +- src/hooks/useLoadReportActions.ts | 15 +- src/hooks/useReportActionsListModel.ts | 27 +- src/hooks/useReportActionsPaginationScroll.ts | 321 ++++++++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 236 ++++++++----- .../ReportActionsPaginationSkeleton.tsx | 77 +++++ src/selectors/ReportMetaData.ts | 14 +- tests/ui/PaginationTest.tsx | 67 ++-- tests/ui/ReportActionsListTest.tsx | 236 ++++++++++--- tests/unit/LegendListMockTest.tsx | 6 + tests/unit/ReportActionsListThresholdTest.tsx | 7 +- .../ReportActionsPaginationSkeletonTest.tsx | 125 +++++++ .../useReportActionsPaginationScrollTest.tsx | 299 ++++++++++++++++ 13 files changed, 1282 insertions(+), 178 deletions(-) create mode 100644 src/hooks/useReportActionsPaginationScroll.ts create mode 100644 src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx create mode 100644 tests/unit/ReportActionsPaginationSkeletonTest.tsx create mode 100644 tests/unit/useReportActionsPaginationScrollTest.tsx diff --git a/jest/setup.ts b/jest/setup.ts index 28caafec6299..3ed3d3d8e3bb 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -49,10 +49,18 @@ jest.mock('@legendapp/list/react-native', () => { maintainVisibleContentPosition?: unknown; recycleItems?: boolean; }; + type MockLegendListState = { + contentLength: number; + scroll: number; + scrollLength: number; + }; + type MockLegendListRef = FlashListModule.FlashListRef & { + getState: () => MockLegendListState | undefined; + }; return { ...LegendListModuleActual, - LegendList: ReactActual.forwardRef, MockLegendListProps>( + LegendList: ReactActual.forwardRef( ( { alignItemsAtEnd, @@ -70,19 +78,35 @@ jest.mock('@legendapp/list/react-native', () => { }, ref, ) => { + const listStateRef = ReactActual.useRef(undefined); const initialScrollConfig = typeof initialScrollIndex === 'number' ? {index: initialScrollIndex} : initialScrollIndex; const flashListInitialScrollIndex = initialScrollAtEnd && data.length > 0 ? data.length - 1 : initialScrollConfig?.index; const flashListInitialScrollIndexParams = initialScrollAtEnd ? {viewPosition: 1} : initialScrollConfig && {viewOffset: initialScrollConfig.viewOffset, viewPosition: initialScrollConfig.viewPosition}; const handleScroll: NonNullable['onScroll']> = (event) => { - onScroll?.(event); const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + listStateRef.current = { + contentLength: contentSize.height, + scroll: contentOffset.y, + scrollLength: layoutMeasurement.height, + }; + onScroll?.(event); const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y; if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) { onEndReached?.(); } }; + const setRef = (instance: FlashListModule.FlashListRef | null) => { + const mockInstance = instance && Object.assign(instance, {getState: () => listStateRef.current}); + if (typeof ref === 'function') { + ref(mockInstance); + return; + } + if (ref) { + Object.assign(ref, {current: mockInstance}); + } + }; return ReactActual.createElement(FlashListActual, { ...props, @@ -90,7 +114,7 @@ jest.mock('@legendapp/list/react-native', () => { initialScrollIndex: flashListInitialScrollIndex, initialScrollIndexParams: flashListInitialScrollIndexParams, onScroll: handleScroll, - ref, + ref: setRef, }); }, ), diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 140dd63b9ef5..04456142df61 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -76,6 +76,15 @@ function useLoadReportActions({ } } + const currentReportNewestActionID = newestFetchedReportActionID ?? currentReportNewestAction?.reportActionID; + const newestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportNewestActionID, transactionThreadNewestAction?.reportActionID]) + : (currentReportNewestActionID ?? newestReportAction?.reportActionID); + const oldestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportOldestAction?.reportActionID, transactionThreadOldestAction?.reportActionID]) + : currentReportOldestAction?.reportActionID; + const canLoadNewerChats = !!isFocused && !!newestReportAction && newestReportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently * displaying. @@ -102,7 +111,7 @@ function useLoadReportActions({ const loadNewerChats = (force = false) => { if ( !force && - (!isFocused || + (!canLoadNewerChats || !newestReportAction || !hasNewerActions || isOffline || @@ -138,6 +147,10 @@ function useLoadReportActions({ loadNewerChats, // The exact cursor `loadOlderChats` sends, which is not always the end of the rendered chain. currentReportOldestActionID: currentReportOldestAction?.reportActionID, + currentReportNewestActionID, + oldestReportActionsRequestCursor, + newestReportActionsRequestCursor, + canLoadNewerChats, }; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 67e39bdd688d..a7b0fc35fbab 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -58,6 +58,8 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; + const isLoadingNewerReportActions = reportLoadingState?.isLoadingNewerReportActions; + const hasLoadingNewerReportActionsError = reportLoadingState?.hasLoadingNewerReportActionsError; const {sessionStartTime, showFullHistory: conciergeShowFullHistory, hadMessagesAtSessionStart: conciergeHadMessagesAtSessionStart} = useConciergeSessionState(); const {setShowFullHistory: setConciergeShowFullHistory, setHadMessagesAtSessionStart: setConciergeHadMessagesAtSessionStart} = useConciergeSessionActions(); @@ -71,15 +73,16 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportID}`); - const {loadOlderChats, loadNewerChats, currentReportOldestActionID} = useLoadReportActions({ - reportID, - reportActions, - allReportActionIDs, - transactionThreadReportID, - hasOlderActions, - hasNewerActions, - newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, - }); + const {loadOlderChats, loadNewerChats, currentReportOldestActionID, currentReportNewestActionID, oldestReportActionsRequestCursor, newestReportActionsRequestCursor, canLoadNewerChats} = + useLoadReportActions({ + reportID, + reportActions, + allReportActionIDs, + transactionThreadReportID, + hasOlderActions, + hasNewerActions, + newestFetchedReportActionID: reportPaginationState?.newestFetchedReportActionID, + }); const { sortedReportActions, @@ -150,7 +153,13 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea hasNewerActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, oldestReportActionID: currentReportOldestActionID, + newestReportActionID: currentReportNewestActionID, + olderReportActionsRequestCursor: oldestReportActionsRequestCursor, + newerReportActionsRequestCursor: newestReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsPaginationScroll.ts b/src/hooks/useReportActionsPaginationScroll.ts new file mode 100644 index 000000000000..634bc47c0128 --- /dev/null +++ b/src/hooks/useReportActionsPaginationScroll.ts @@ -0,0 +1,321 @@ +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import type {LegendListRef} from '@legendapp/list/react-native'; +import type {RefObject} from 'react'; + +import {useEffect, useEffectEvent, useLayoutEffect, useRef} from 'react'; + +const REPORT_ACTIONS_PAGINATION_THRESHOLD = 0.25; + +type PaginationGeometry = { + scroll: number; + scrollLength: number; + contentLength: number; +}; + +type PaginationListRef = { + getState: () => PaginationGeometry | undefined; + scrollToOffset: LegendListRef['scrollToOffset']; +}; + +type ReportActionsPaginationDistances = { + older: number; + newer: number; +}; + +type UseReportActionsPaginationScrollArguments = { + reportID: string; + linkedReportActionID: string | undefined; + listRef: RefObject; + viewportHeight: number; + olderPaginationExtent: number; + newerPaginationExtent: number; + olderCursor: string | undefined; + newerCursor: string | undefined; + hasOlderActions: boolean; + hasNewerActions: boolean; + isLoadingOlderReportActions: boolean; + isLoadingNewerReportActions: boolean; + hasLoadingOlderReportActionsError: boolean; + hasLoadingNewerReportActionsError: boolean; + isOffline: boolean; + canLoadOlder: boolean; + canLoadNewer: boolean; + loadOlderActions: () => void; + loadNewerActions: () => void; +}; + +function useReportActionsPaginationScroll(options: UseReportActionsPaginationScrollArguments) { + const { + reportID, + linkedReportActionID, + olderCursor, + newerCursor, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isOffline, + canLoadOlder, + canLoadNewer, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + hasOlderActions, + hasNewerActions, + } = options; + const latestArgumentsRef = useRef(options); + const lastRequestedOlderCursorRef = useRef(undefined); + const lastRequestedNewerCursorRef = useRef(undefined); + const wasNearOlderBoundaryRef = useRef(false); + const wasNearNewerBoundaryRef = useRef(false); + const previousOlderCursorRef = useRef(undefined); + const previousNewerCursorRef = useRef(undefined); + const wasOfflineRef = useRef(isOffline); + const couldLoadOlderRef = useRef(canLoadOlder); + const couldLoadNewerRef = useRef(canLoadNewer); + const wasLoadingOlderRef = useRef(isLoadingOlderReportActions); + const wasLoadingNewerRef = useRef(isLoadingNewerReportActions); + const pendingOlderRevealCursorRef = useRef(undefined); + const paginationWindowGenerationRef = useRef(0); + const scheduledFramesRef = useRef(new Set()); + + useLayoutEffect(() => { + latestArgumentsRef.current = options; + }); + + const scheduleFrame = (callback: () => void, generation?: number) => { + const expectedGeneration = generation ?? paginationWindowGenerationRef.current; + const frame = requestAnimationFrame(() => { + scheduledFramesRef.current.delete(frame); + if (expectedGeneration === paginationWindowGenerationRef.current) { + callback(); + } + }); + scheduledFramesRef.current.add(frame); + }; + + const cancelScheduledFrames = () => { + for (const frame of scheduledFramesRef.current) { + cancelAnimationFrame(frame); + } + scheduledFramesRef.current.clear(); + }; + + const requestNewerActions = (cursor: string, canRetryError: boolean) => { + const runRequest = () => { + const latestArguments = latestArgumentsRef.current; + if ( + latestArguments.newerCursor !== cursor || + lastRequestedNewerCursorRef.current !== cursor || + !latestArguments.canLoadNewer || + !latestArguments.hasNewerActions || + latestArguments.isOffline || + latestArguments.isLoadingNewerReportActions || + (latestArguments.hasLoadingNewerReportActionsError && !canRetryError) + ) { + return; + } + latestArguments.loadNewerActions(); + }; + + if (!isSearchTopmostFullScreenRoute()) { + runRequest(); + return; + } + + const generation = paginationWindowGenerationRef.current; + TransitionTracker.runAfterTransitions({callback: () => scheduleFrame(runRequest, generation)}); + }; + + const checkPaginationBoundaries = () => { + const latestArguments = latestArgumentsRef.current; + const listState = latestArguments.listRef.current?.getState(); + if (!listState || latestArguments.viewportHeight <= 0) { + return; + } + + const distances = getReportActionsPaginationDistances(listState, latestArguments.olderPaginationExtent, latestArguments.newerPaginationExtent); + const threshold = latestArguments.viewportHeight * REPORT_ACTIONS_PAGINATION_THRESHOLD; + const isNearOlderBoundary = distances.older <= threshold; + const isNearNewerBoundary = distances.newer <= threshold; + const isWaitingForOlderReveal = pendingOlderRevealCursorRef.current === latestArguments.olderCursor; + + if (!isNearOlderBoundary) { + lastRequestedOlderCursorRef.current = undefined; + } + if (!isNearNewerBoundary) { + lastRequestedNewerCursorRef.current = undefined; + } + + const canRetryOlderError = !wasNearOlderBoundaryRef.current; + const canRetryNewerError = !wasNearNewerBoundaryRef.current; + + if ( + isNearOlderBoundary && + !isWaitingForOlderReveal && + latestArguments.canLoadOlder && + latestArguments.hasOlderActions && + !latestArguments.isOffline && + !latestArguments.isLoadingOlderReportActions && + (!latestArguments.hasLoadingOlderReportActionsError || canRetryOlderError) && + latestArguments.olderCursor && + lastRequestedOlderCursorRef.current !== latestArguments.olderCursor + ) { + lastRequestedOlderCursorRef.current = latestArguments.olderCursor; + latestArguments.loadOlderActions(); + } + + if ( + isNearNewerBoundary && + latestArguments.canLoadNewer && + latestArguments.hasNewerActions && + !latestArguments.isOffline && + !latestArguments.isLoadingNewerReportActions && + (!latestArguments.hasLoadingNewerReportActionsError || canRetryNewerError) && + latestArguments.newerCursor && + lastRequestedNewerCursorRef.current !== latestArguments.newerCursor + ) { + lastRequestedNewerCursorRef.current = latestArguments.newerCursor; + requestNewerActions(latestArguments.newerCursor, canRetryNewerError); + } + + wasNearOlderBoundaryRef.current = isNearOlderBoundary; + wasNearNewerBoundaryRef.current = isNearNewerBoundary; + }; + + const schedulePaginationBoundaryCheck = () => scheduleFrame(checkPaginationBoundaries); + const cancelScheduledFramesEffect = useEffectEvent(cancelScheduledFrames); + const schedulePaginationBoundaryCheckEffect = useEffectEvent(schedulePaginationBoundaryCheck); + const scheduleOlderRevealEffect = useEffectEvent((advancedCursor: string | undefined) => { + scheduleFrame(() => { + scheduleFrame(() => { + const latestArguments = latestArgumentsRef.current; + const listState = latestArguments.listRef.current?.getState(); + if ( + advancedCursor === latestArguments.olderCursor && + latestArguments.hasOlderActions && + listState && + listState.scroll + listState.scrollLength <= latestArguments.olderPaginationExtent + ) { + latestArguments.listRef.current?.scrollToOffset({offset: latestArguments.olderPaginationExtent, animated: false}); + } + if (pendingOlderRevealCursorRef.current === advancedCursor) { + pendingOlderRevealCursorRef.current = undefined; + } + checkPaginationBoundaries(); + }); + }); + }); + + useEffect(() => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + pendingOlderRevealCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [reportID, linkedReportActionID]); + + useEffect(() => { + if (previousOlderCursorRef.current !== olderCursor) { + const previousOlderCursor = previousOlderCursorRef.current; + const didAdvanceAfterOwnRequest = !!previousOlderCursor && lastRequestedOlderCursorRef.current === previousOlderCursor; + previousOlderCursorRef.current = olderCursor; + lastRequestedOlderCursorRef.current = undefined; + if (didAdvanceAfterOwnRequest && hasOlderActions) { + const advancedCursor = olderCursor; + pendingOlderRevealCursorRef.current = advancedCursor; + scheduleOlderRevealEffect(advancedCursor); + } else { + pendingOlderRevealCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + } + } + if (previousNewerCursorRef.current !== newerCursor) { + previousNewerCursorRef.current = newerCursor; + lastRequestedNewerCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + } + }, [olderCursor, newerCursor, hasOlderActions]); + + useEffect(() => { + const finishedLoadingOlder = wasLoadingOlderRef.current && !isLoadingOlderReportActions; + const finishedLoadingNewer = wasLoadingNewerRef.current && !isLoadingNewerReportActions; + wasLoadingOlderRef.current = isLoadingOlderReportActions; + wasLoadingNewerRef.current = isLoadingNewerReportActions; + if (finishedLoadingOlder || finishedLoadingNewer) { + schedulePaginationBoundaryCheckEffect(); + } + }, [isLoadingOlderReportActions, isLoadingNewerReportActions]); + + useEffect(() => { + const didReconnect = wasOfflineRef.current && !isOffline; + wasOfflineRef.current = isOffline; + if (!didReconnect) { + return; + } + + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + pendingOlderRevealCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [isOffline]); + + useEffect(() => { + const canNowLoadOlder = !couldLoadOlderRef.current && canLoadOlder; + couldLoadOlderRef.current = canLoadOlder; + if (!canNowLoadOlder) { + return; + } + lastRequestedOlderCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadOlder]); + + useEffect(() => { + const canNowLoadNewer = !couldLoadNewerRef.current && canLoadNewer; + couldLoadNewerRef.current = canLoadNewer; + if (!canNowLoadNewer) { + return; + } + lastRequestedNewerCursorRef.current = undefined; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadNewer]); + + useEffect(() => { + schedulePaginationBoundaryCheckEffect(); + }, [viewportHeight, olderPaginationExtent, newerPaginationExtent, hasOlderActions, hasNewerActions]); + + useEffect( + () => () => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + }, + [], + ); + + return { + onScroll: checkPaginationBoundaries, + onContentSizeChange: schedulePaginationBoundaryCheck, + }; +} + +function getReportActionsPaginationDistances( + {scroll, scrollLength, contentLength}: PaginationGeometry, + olderPaginationExtent: number, + newerPaginationExtent: number, +): ReportActionsPaginationDistances { + return { + older: scroll - olderPaginationExtent, + newer: contentLength - scrollLength - scroll - newerPaginationExtent, + }; +} + +export default useReportActionsPaginationScroll; +export {getReportActionsPaginationDistances, REPORT_ACTIONS_PAGINATION_THRESHOLD}; +export type {PaginationGeometry, ReportActionsPaginationDistances, UseReportActionsPaginationScrollArguments}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 28e6f4ba4097..bb2969b8dbd0 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -9,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useMarkAsRead from '@hooks/useMarkAsRead'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useReportActionsPaginationScroll from '@hooks/useReportActionsPaginationScroll'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,9 +17,7 @@ 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, @@ -71,6 +70,7 @@ import {useReportActionsListActions, useReportActionsListState} from './ReportAc import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; +import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from './ReportActionsPaginationSkeleton'; import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; @@ -85,7 +85,6 @@ type ReportActionsListContentProps = { type ReportActionsListProps = ReportActionsListContentProps; -const PAGINATION_THRESHOLD = 0.75; const REPORT_ACTIONS_DRAW_DISTANCE = 1500; const REPORT_ACTION_COMMENT_SIZE = { @@ -160,7 +159,13 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct hasNewerActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, oldestReportActionID, + newestReportActionID, + olderReportActionsRequestCursor, + newerReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -182,21 +187,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {sessionStartTime} = useConciergeSessionState(); 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}); // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting @@ -237,6 +233,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); const legendListRef = useRef(null); + const [viewportHeight, setViewportHeight] = useState(0); + const [newerFooterHeight, setNewerFooterHeight] = useState(0); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); useImperativeHandle( listRef, @@ -259,6 +258,32 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; const onShowPreviousMessages = handleShowPreviousMessages; + const shouldShowOlderPaginationSkeleton = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; + const shouldShowNewerPaginationSkeleton = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; + const olderPaginationExtent = shouldShowOlderPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT + PAGINATION_TOP_OVERLAY_CLEARANCE : 0; + const newerPaginationExtent = shouldShowNewerPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT + newerFooterHeight : 0; + + const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ + reportID, + linkedReportActionID: reportActionIDFromRoute, + listRef: legendListRef, + viewportHeight, + olderPaginationExtent, + newerPaginationExtent, + olderCursor: olderReportActionsRequestCursor ?? oldestReportActionID, + newerCursor: newerReportActionsRequestCursor ?? newestReportActionID, + hasOlderActions, + hasNewerActions, + isLoadingOlderReportActions: !!isLoadingOlderReportActions, + isLoadingNewerReportActions: !!isLoadingNewerReportActions, + hasLoadingOlderReportActionsError: !!hasLoadingOlderReportActionsError, + hasLoadingNewerReportActionsError: !!hasLoadingNewerReportActionsError, + isOffline, + canLoadOlder: !showHiddenHistory && loadedInitialViewportListID === listID, + canLoadNewer: canLoadNewerChats && loadedInitialViewportListID === listID, + loadOlderActions: () => loadOlderChats(false), + loadNewerActions: () => loadNewerChats(false), + }); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); @@ -387,7 +412,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setTreatAsNoPaginationAnchor, }); - const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); const handleListLoad = () => { @@ -395,25 +419,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setLoadedInitialViewportListID(listID); }; - const loadOlderChatsOnStartReached = () => { - if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { - return; - } - - 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 isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; - - if (isNearStart) { - loadOlderChatsOnStartReached(); - } else { - lastRequestedOldestActionIDRef.current = undefined; - } + checkPaginationOnScroll(); const bottomRelativeEvent = { ...event, @@ -428,19 +437,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct emitComposerScrollEvents(); }; - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; - } - - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); - }, - }); - }; - const firstVisibleReportActionID = getFirstVisibleReportActionID(sortedReportActions, isOffline); useFollowActionBadgeTarget({ @@ -525,16 +521,48 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isDraftPendingCompletion, ]; - const listHeaderComponent = ( - + const handleViewportLayout = (event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }; + + const handleNewerFooterLayout = (event: LayoutChangeEvent) => { + setNewerFooterHeight(event.nativeEvent.layout.height); + }; + + const newerListFooterComponent = ( + <> + + + + {shouldShowNewerPaginationSkeleton && ( + + )} + ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const olderListHeaderComponent = ( + <> + {shouldShowOlderPaginationSkeleton && ( + + )} + {shouldShowOfflineSkeleton && } + + ); const shouldShowMarkAsDoneCopy = shouldShowMarkAsDone({ policy, @@ -563,6 +591,22 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ; } + const newerPaginationPageExtent = shouldShowNewerPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT : 0; + const shouldTargetNewestRealAction = initialScrollIndex === undefined && shouldShowNewerPaginationSkeleton && listData.length > 0; + const initialListIndex = (() => { + if (initialScrollIndex !== undefined) { + return {index: initialScrollIndex, ...initialScrollIndexParams}; + } + if (shouldTargetNewestRealAction) { + return { + index: listData.length - 1, + viewPosition: 1, + viewOffset: newerPaginationPageExtent, + }; + } + return undefined; + })(); + return ( <> - trackVerticalScrolling(undefined)} - /> - {shouldShowInitialViewportSkeleton && ( - + + {viewportHeight > 0 ? ( + { + trackVerticalScrolling(undefined); + checkPaginationOnContentSizeChange(); + }} + /> + ) : ( - - )} + )} + {viewportHeight > 0 && shouldShowInitialViewportSkeleton && ( + + + + )} + ); diff --git a/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx b/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx new file mode 100644 index 000000000000..bf964b07b279 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx @@ -0,0 +1,77 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +type PaginationDirection = 'older' | 'newer'; + +type ReportActionsPaginationSkeletonProps = { + direction: PaginationDirection; + viewportHeight: number; + isLoading: boolean; + hasError: boolean; +}; + +const PAGINATION_SKELETON_ROW_HEIGHT = CONST.CHAT_SKELETON_VIEW.HEIGHT_FOR_ROW_COUNT[1]; +const PAGINATION_SPINNER_HEIGHT = 48; +const PAGINATION_TOP_OVERLAY_CLEARANCE = CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; + +const styles = StyleSheet.create({ + page: { + overflow: 'hidden', + }, + spinnerSlot: { + alignItems: 'center', + height: PAGINATION_SPINNER_HEIGHT, + justifyContent: 'center', + }, +}); + +function ReportActionsPaginationSkeleton({direction, viewportHeight, isLoading, hasError}: ReportActionsPaginationSkeletonProps) { + const testID = `report-actions-pagination-${direction}`; + const page = ( + + + + ); + const spinner = ( + + {isLoading && !hasError && ( + + )} + + ); + + return ( + + {direction === 'older' && } + {direction === 'older' && spinner} + {page} + {direction === 'newer' && spinner} + + ); +} + +export default ReportActionsPaginationSkeleton; +export {PAGINATION_SKELETON_ROW_HEIGHT, PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE}; +export type {PaginationDirection, ReportActionsPaginationSkeletonProps}; diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts index 12e9d65eae98..8d95a2397768 100644 --- a/src/selectors/ReportMetaData.ts +++ b/src/selectors/ReportMetaData.ts @@ -18,13 +18,25 @@ const reportActionsLoadingStateSelector = (loadingState: OnyxEntry, -): Pick | undefined => +): + | Pick< + ReportLoadingState, + | 'hasOnceLoadedReportActions' + | 'isLoadingInitialReportActions' + | 'isLoadingOlderReportActions' + | 'hasLoadingOlderReportActionsError' + | 'isLoadingNewerReportActions' + | 'hasLoadingNewerReportActionsError' + > + | undefined => loadingState ? { hasOnceLoadedReportActions: loadingState.hasOnceLoadedReportActions, isLoadingInitialReportActions: loadingState.isLoadingInitialReportActions, isLoadingOlderReportActions: loadingState.isLoadingOlderReportActions, hasLoadingOlderReportActionsError: loadingState.hasLoadingOlderReportActionsError, + isLoadingNewerReportActions: loadingState.isLoadingNewerReportActions, + hasLoadingNewerReportActionsError: loadingState.hasLoadingNewerReportActionsError, } : undefined; diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index f393af39fb38..1b2f7d6d849f 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -85,6 +85,16 @@ function triggerListLayout(reportID?: string) { persist: () => {}, }); + fireEvent(within(report).getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: { + x: 0, + y: 0, + ...LIST_SIZE, + }, + }, + }); + fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); } @@ -173,26 +183,31 @@ function mockGetOlderActions(messageCount: number) { }, ] : [], - hasOlderActions: comments['1'] != null, + hasOlderActions: !comments['1'], }; }); } -function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ - onyxData: - reportID === REPORT_ID - ? [ - { - onyxMethod: 'merge', - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, - // The API also returns the action that was requested with the reportActionID. - value: buildReportComments(messageCount + 1, reportActionID, true), - }, - ] - : [], - hasNewerActions: messageCount > 0, - })); +function mockGetNewerActions(...messageCounts: number[]) { + let callIndex = 0; + fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => { + const messageCount = messageCounts[Math.min(callIndex, messageCounts.length - 1)]; + callIndex += 1; + return { + onyxData: + reportID === REPORT_ID + ? [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + // The API also returns the action that was requested with the reportActionID. + value: buildReportComments(messageCount + 1, reportActionID, true), + }, + ] + : [], + hasNewerActions: messageCount > 0, + }; + }); } async function fastSignInWithTestUser() { @@ -360,7 +375,7 @@ describe('Pagination', () => { it('opens a chat and load newer messages', async () => { mockOpenReport(5, '5'); - mockGetNewerActions(5); + mockGetNewerActions(5, 0); await signInAndGetApp(); await navigateToSidebarOption(COMMENT_LINKING_REPORT_ID); @@ -370,24 +385,18 @@ describe('Pagination', () => { await waitFor(() => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); - // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // 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(); + scrollToOffset(LIST_END_OFFSET); + await waitForNetworkPromises(); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2)); await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); - // Here we have 5 messages from the initial OpenReport and 5 from the initial GetNewerActions. + // The first newer page advances the cursor while the viewport remains at the boundary, so pagination + // requests the next page and stops when the backend reports that there are no more newer actions. expect(getReportActions()).toHaveLength(10); - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); - - // There is 1 extra call here because of the comment linking report. - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 60eed393ac14..45313e33974a 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {act, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -20,6 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; +import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from '@pages/inbox/report/ReportActionsPaginationSkeleton'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -85,9 +86,26 @@ const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunc const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { +function getMockReportLoadingState( + selector: unknown, + hasOnceLoadedReportActions = true, + paginationState: { + isLoadingOlderReportActions?: boolean; + hasLoadingOlderReportActionsError?: boolean; + isLoadingNewerReportActions?: boolean; + hasLoadingNewerReportActionsError?: boolean; + } = {}, +) { return selector === reportActionsListLoadingStateSelector - ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false} + ? { + hasOnceLoadedReportActions, + isLoadingInitialReportActions: false, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, + ...paginationState, + } : undefined; } @@ -116,11 +134,13 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); const mockLoadOlderChats = jest.fn(); +const mockLoadNewerChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ loadOlderChats: mockLoadOlderChats, - loadNewerChats: jest.fn(), + loadNewerChats: mockLoadNewerChats, currentReportOldestActionID: reportActions.at(-1)?.reportActionID, + currentReportNewestActionID: reportActions.at(0)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -154,6 +174,8 @@ jest.mock('@legendapp/list/react-native', () => { }); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); +let mockInitialScrollIndex: number | undefined; +let mockInitialScrollIndexParams: {viewOffset?: number; viewPosition?: number} | undefined; jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, @@ -164,8 +186,8 @@ jest.mock('@hooks/useReportActionsScroll', () => scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), shouldBeAlignedToTop: false, - initialScrollIndex: undefined, - initialScrollIndexParams: undefined, + initialScrollIndex: mockInitialScrollIndex, + initialScrollIndexParams: mockInitialScrollIndexParams, onLoad: jest.fn(), })), ); @@ -184,10 +206,16 @@ type MockLegendListProps = { extraData?: unknown; getItemType?: (item: OnyxTypes.ReportAction) => string; initialScrollAtEnd?: boolean; + initialScrollIndex?: {index: number; viewOffset?: number; viewPosition?: number}; + estimatedHeaderSize?: number; maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + ListFooterComponentStyle?: unknown; onLoad?: () => void; + onContentSizeChange?: (width: number, height: number) => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -200,6 +228,8 @@ type MockLegendListProps = { }) => void; }; +type PaginationSkeletonProps = React.ComponentProps; + 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'); @@ -207,6 +237,24 @@ const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/re const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); +function findPaginationSkeleton(node: React.ReactNode): React.ReactElement | undefined { + if (!React.isValidElement<{children?: React.ReactNode}>(node)) { + return undefined; + } + if (node.type === ReportActionsPaginationSkeleton) { + return node as React.ReactElement; + } + + for (const child of React.Children.toArray(node.props.children)) { + const paginationSkeleton = findPaginationSkeleton(child); + if (paginationSkeleton) { + return paginationSkeleton; + } + } + + return undefined; +} + const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAction, index = 0): {shouldDisableContextMenuForConciergeDraft?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -230,6 +278,10 @@ const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); let mockHasOnceLoadedReportActions = true; +let mockIsLoadingOlderReportActions = false; +let mockHasLoadingOlderReportActionsError = false; +let mockIsLoadingNewerReportActions = false; +let mockHasLoadingNewerReportActionsError = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -287,12 +339,19 @@ const olderMockReportAction: OnyxTypes.ReportAction = { const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; - return render( + const view = render( , ); + const viewport = screen.queryByTestId('report-actions-list-viewport'); + if (viewport) { + fireEvent(viewport, 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + } + return view; }; describe('ReportActionsList (body)', () => { @@ -305,6 +364,12 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockInitialScrollIndex = undefined; + mockInitialScrollIndexParams = undefined; + mockIsLoadingOlderReportActions = false; + mockHasLoadingOlderReportActionsError = false; + mockIsLoadingNewerReportActions = false; + mockHasLoadingNewerReportActionsError = false; mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); @@ -368,7 +433,15 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions), {status: 'loaded'}]; + return [ + getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, { + isLoadingOlderReportActions: mockIsLoadingOlderReportActions, + hasLoadingOlderReportActionsError: mockHasLoadingOlderReportActionsError, + isLoadingNewerReportActions: mockIsLoadingNewerReportActions, + hasLoadingNewerReportActionsError: mockHasLoadingNewerReportActionsError, + }), + {status: 'loaded'}, + ]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -572,52 +645,127 @@ describe('ReportActionsList (body)', () => { ).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({ - ...defaultPaginatedReportActionsResult, - reportActions: mockReportActions, - hasOlderActions: true, - }); - const view = renderReportActionsList(); + describe('pagination placeholders', () => { + it('measures the chat viewport before mounting LegendList', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); - const listProps = getCapturedListProps(); - const createScrollEvent = (offset: number) => ({ - nativeEvent: { - contentOffset: {x: 0, y: offset}, - contentSize: {height: 1000, width: 300}, - layoutMeasurement: {height: 500, width: 300}, - }, + render( + , + ); + + expect(mockLegendList).not.toHaveBeenCalled(); + + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}, + }); + + expect(mockLegendList).toHaveBeenCalled(); }); - act(() => { - listProps?.onScroll?.(createScrollEvent(0)); + it('keeps one measured skeleton page at both available edges while requests are idle or loading', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + let olderSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent); + let newerSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent); + expect(olderSkeleton?.props).toEqual(expect.objectContaining({direction: 'older', viewportHeight: 500, isLoading: false, hasError: false})); + expect(newerSkeleton?.props).toEqual(expect.objectContaining({direction: 'newer', viewportHeight: 500, isLoading: false, hasError: false})); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(500 + PAGINATION_SPINNER_HEIGHT + PAGINATION_TOP_OVERLAY_CLEARANCE); + expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); + + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + view.rerender( + , + ); + + olderSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent); + newerSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent); + expect(olderSkeleton?.props.isLoading).toBe(true); + expect(newerSkeleton?.props.isLoading).toBe(true); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); - act(() => { - listProps?.onStartReached?.(); - listProps?.onScroll?.(createScrollEvent(0)); + it('positions an unanchored newer window at the last real action before the skeleton page', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: mockReportActions.length - 1, viewPosition: 1, viewOffset: 548}); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); - mockUsePaginatedReportActions.mockReturnValue({ - ...defaultPaginatedReportActionsResult, - reportActions: [...mockReportActions, olderMockReportAction], - hasOlderActions: true, + it('preserves an explicit linked-action index when a newer skeleton page is present', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockInitialScrollIndex = 0; + mockInitialScrollIndexParams = {viewPosition: 0.5, viewOffset: 12}; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: 0, viewPosition: 0.5, viewOffset: 12}); }); - view.rerender( - , - ); - act(() => { - getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + it('removes only exhausted edge pages and omits pagination placeholders offline', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: false, + hasNewerActions: true, + }); + view.rerender( + , + ); + + expect(findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + + mockUseNetwork.mockReturnValue({isOffline: true}); + view.rerender( + , + ); + + expect(findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); }); - expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); }); describe('Concierge Draft Context Menu', () => { diff --git a/tests/unit/LegendListMockTest.tsx b/tests/unit/LegendListMockTest.tsx index bcf57399e1c2..b8e0c6777526 100644 --- a/tests/unit/LegendListMockTest.tsx +++ b/tests/unit/LegendListMockTest.tsx @@ -1,7 +1,10 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; +import type {LegendListRef} from '@legendapp/list/react-native'; + import {LegendList} from '@legendapp/list/react-native'; import {FlashList} from '@shopify/flash-list'; +import {createRef} from 'react'; const DATA = ['first', 'second', 'third']; @@ -37,11 +40,13 @@ describe('LegendList Jest mock', () => { it('invokes onEndReached only in response to a matching scroll event', () => { const onEndReached = jest.fn(); + const listRef = createRef(); const renderResult = render( , @@ -60,5 +65,6 @@ describe('LegendList Jest mock', () => { }); expect(onEndReached).toHaveBeenCalledTimes(1); + expect(listRef.current?.getState()).toEqual(expect.objectContaining({contentLength: 600, scroll: 100, scrollLength: 400})); }); }); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index 61c2508361be..3da59cb457ff 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -1,4 +1,4 @@ -import {act, render, waitFor} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -128,6 +128,11 @@ async function renderList(initialOffset: number) { , ); + fireEvent(screen.getByTestId('report-actions-list-viewport'), 'onLayout', { + nativeEvent: { + layout: {x: 0, y: 0, width: 300, height: 500}, + }, + }); await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); return utils; } diff --git a/tests/unit/ReportActionsPaginationSkeletonTest.tsx b/tests/unit/ReportActionsPaginationSkeletonTest.tsx new file mode 100644 index 000000000000..3d0993ee4b2c --- /dev/null +++ b/tests/unit/ReportActionsPaginationSkeletonTest.tsx @@ -0,0 +1,125 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from '@pages/inbox/report/ReportActionsPaginationSkeleton'; + +import type {ComponentType} from 'react'; +import type {ViewProps} from 'react-native'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +const mockReportActionsSkeletonView = jest.fn((props: {possibleVisibleContentItems?: number; shouldAnimate?: boolean}) => ( + +)); + +jest.mock('@components/ReportActionsSkeletonView', () => ({ + __esModule: true, + default: (props: {possibleVisibleContentItems?: number; shouldAnimate?: boolean}) => mockReportActionsSkeletonView(props), +})); + +jest.mock('@components/ActivityIndicator', () => ({ + __esModule: true, + default: ({testID}: {testID?: string}) => { + const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); + return ; + }, +})); + +const VIEWPORT_HEIGHT = 361; +const OLDER_TEST_ID = 'report-actions-pagination-older'; +const NEWER_TEST_ID = 'report-actions-pagination-newer'; + +describe('ReportActionsPaginationSkeleton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('covers one full viewport with enough static skeleton rows', () => { + render( + , + ); + + expect(StyleSheet.flatten(screen.getByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({height: VIEWPORT_HEIGHT, overflow: 'hidden'}), + ); + expect(mockReportActionsSkeletonView).toHaveBeenCalledWith({possibleVisibleContentItems: Math.ceil(VIEWPORT_HEIGHT / 60), shouldAnimate: false}); + }); + + it('puts the fixed spinner slot beyond the skeleton in each pagination direction', () => { + const view = render( + , + ); + + expect(view.toJSON()).toEqual( + expect.objectContaining({ + children: [ + expect.objectContaining({props: expect.objectContaining({style: {height: PAGINATION_TOP_OVERLAY_CLEARANCE}})}), + expect.objectContaining({props: expect.objectContaining({testID: `${OLDER_TEST_ID}-spinner-slot`})}), + expect.objectContaining({props: expect.objectContaining({testID: `${OLDER_TEST_ID}-skeleton`})}), + ], + }), + ); + expect(StyleSheet.flatten(screen.getByTestId(`${OLDER_TEST_ID}-spinner-slot`, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({height: PAGINATION_SPINNER_HEIGHT}), + ); + expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + + view.rerender( + , + ); + + expect(view.toJSON()).toEqual( + expect.objectContaining({ + children: [ + expect.objectContaining({props: expect.objectContaining({testID: `${NEWER_TEST_ID}-skeleton`})}), + expect.objectContaining({props: expect.objectContaining({testID: `${NEWER_TEST_ID}-spinner-slot`})}), + ], + }), + ); + expect(StyleSheet.flatten(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({height: PAGINATION_SPINNER_HEIGHT}), + ); + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + }); + + it('keeps the spinner slot stable while hiding the spinner when idle or failed', () => { + const view = render( + , + ); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeNull(); + + view.rerender( + , + ); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeNull(); + }); +}); diff --git a/tests/unit/useReportActionsPaginationScrollTest.tsx b/tests/unit/useReportActionsPaginationScrollTest.tsx new file mode 100644 index 000000000000..4abd29d51c29 --- /dev/null +++ b/tests/unit/useReportActionsPaginationScrollTest.tsx @@ -0,0 +1,299 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportActionsPaginationScroll, {REPORT_ACTIONS_PAGINATION_THRESHOLD} from '@hooks/useReportActionsPaginationScroll'; + +const VIEWPORT_HEIGHT = 500; +const NEWER_PAGINATION_EXTENT = VIEWPORT_HEIGHT + 48; +const OLDER_PAGINATION_EXTENT = NEWER_PAGINATION_EXTENT + 40; +const CONTENT_HEIGHT = 3000; +const BOUNDARY_DISTANCE = VIEWPORT_HEIGHT * 0.25; + +const mockLoadOlderActions = jest.fn(); +const mockLoadNewerActions = jest.fn(); +const mockScrollToOffset = jest.fn(); +const mockAnimationFrames: FrameRequestCallback[] = []; +const mockTransitionCallbacks: Array<() => void> = []; +let mockIsSearchTopmostFullScreenRoute = false; + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute); +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: ({callback}: {callback: () => void}) => { + mockTransitionCallbacks.push(callback); + return {cancel: jest.fn()}; + }, + }, +})); + +let listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, +}; + +// The hook reads only these three values from LegendList's much larger diagnostic state. +const listRef = { + current: { + getState: () => listMetrics, + scrollToOffset: mockScrollToOffset, + }, +}; + +type HookParams = Parameters[0]; + +function buildParams(overrides: Partial = {}): HookParams { + return { + reportID: 'report-1', + linkedReportActionID: undefined, + listRef, + viewportHeight: VIEWPORT_HEIGHT, + olderPaginationExtent: OLDER_PAGINATION_EXTENT, + newerPaginationExtent: NEWER_PAGINATION_EXTENT, + olderCursor: 'older-1', + newerCursor: 'newer-1', + hasOlderActions: true, + hasNewerActions: true, + isLoadingOlderReportActions: false, + isLoadingNewerReportActions: false, + hasLoadingOlderReportActionsError: false, + hasLoadingNewerReportActionsError: false, + isOffline: false, + canLoadOlder: true, + canLoadNewer: true, + loadOlderActions: mockLoadOlderActions, + loadNewerActions: mockLoadNewerActions, + ...overrides, + }; +} + +function setListMetrics(offset: number, contentHeight = CONTENT_HEIGHT, viewportHeight = VIEWPORT_HEIGHT) { + listMetrics = { + contentLength: contentHeight, + scroll: offset, + scrollLength: viewportHeight, + }; +} + +function flushAnimationFrames() { + while (mockAnimationFrames.length > 0) { + act(() => { + for (const callback of mockAnimationFrames.splice(0)) { + callback(0); + } + }); + } +} + +function flushTransitions() { + act(() => { + for (const callback of mockTransitionCallbacks.splice(0)) { + callback(); + } + }); +} + +describe('useReportActionsPaginationScroll', () => { + beforeAll(() => { + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + mockAnimationFrames.push(callback); + return mockAnimationFrames.length; + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockAnimationFrames.length = 0; + mockTransitionCallbacks.length = 0; + mockIsSearchTopmostFullScreenRoute = false; + listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, + }; + }); + + it('triggers both directions at 25% of the real-message boundary after subtracting pagination extents', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + const olderBoundaryOffset = OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE; + const newerBoundaryOffset = CONTENT_HEIGHT - VIEWPORT_HEIGHT - NEWER_PAGINATION_EXTENT - BOUNDARY_DISTANCE; + + expect(REPORT_ACTIONS_PAGINATION_THRESHOLD).toBe(0.25); + + act(() => { + setListMetrics(olderBoundaryOffset + 1); + result.current.onScroll(); + setListMetrics(olderBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + setListMetrics(newerBoundaryOffset - 1); + result.current.onScroll(); + setListMetrics(newerBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('deduplicates repeated boundary events for the same actual request cursors', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + + act(() => { + setListMetrics(0); + result.current.onScroll(); + result.current.onScroll(); + setListMetrics(CONTENT_HEIGHT); + result.current.onScroll(); + result.current.onScroll(); + }); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('rechecks stationary geometry after a cursor advances and after content size settles', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, newerCursor: 'newer-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, newerCursor: 'newer-3'}); + act(() => { + result.current.onContentSizeChange(); + }); + listMetrics = {...listMetrics, contentLength: CONTENT_HEIGHT + 100}; + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); + + it('reveals the first real older action when a response arrives while the viewport is entirely in the skeleton', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(0); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockScrollToOffset).toHaveBeenCalledWith({offset: OLDER_PAGINATION_EXTENT, animated: false}); + }); + + it('leaves a partial-skeleton viewport anchored when it already contains a real older action', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(100); + act(() => { + result.current.onScroll(); + }); + rerender({...initialParams, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockScrollToOffset).not.toHaveBeenCalled(); + }); + + it('uses current availability when a scheduled content-size check runs', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(0); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onContentSizeChange(); + }); + rerender({...initialParams, hasOlderActions: false, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('cancels delayed Search requests when the pagination window changes or unmounts', () => { + mockIsSearchTopmostFullScreenRoute = true; + const initialParams = buildParams({hasOlderActions: false}); + const firstView = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + firstView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + firstView.rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + + mockTransitionCallbacks.length = 0; + const secondView = renderHook(() => useReportActionsPaginationScroll(initialParams)); + act(() => { + secondView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + secondView.unmount(); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + }); + + it('blocks loading and stationary error loops but permits a deliberate leave and reentry retry', () => { + const loadingParams = buildParams({isLoadingOlderReportActions: true, hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: loadingParams}); + + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + const failedParams = {...loadingParams, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: true}; + rerender(failedParams); + flushAnimationFrames(); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + act(() => { + setListMetrics(OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE + 1); + result.current.onScroll(); + setListMetrics(0); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('resets request guards after reconnecting or changing the linked-action window', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, isOffline: true}); + rerender(initialParams); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); +}); From 6dcdbca840b43100b67c258723e3bf40e0c3188d Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 19:58:24 +0200 Subject: [PATCH 33/39] refactor: simplify chat pagination loading UI --- src/hooks/useReportActionsPaginationScroll.ts | 58 ++------ src/pages/inbox/report/ReportActionsList.tsx | 52 ++------ ...eportActionsPaginationLoadingIndicator.tsx | 47 +++++++ .../ReportActionsPaginationSkeleton.tsx | 77 ----------- tests/ui/ReportActionsListTest.tsx | 68 ++++++---- ...tActionsPaginationLoadingIndicatorTest.tsx | 56 ++++++++ .../ReportActionsPaginationSkeletonTest.tsx | 125 ------------------ .../useReportActionsPaginationScrollTest.tsx | 36 +---- 8 files changed, 165 insertions(+), 354 deletions(-) create mode 100644 src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx delete mode 100644 src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx create mode 100644 tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx delete mode 100644 tests/unit/ReportActionsPaginationSkeletonTest.tsx diff --git a/src/hooks/useReportActionsPaginationScroll.ts b/src/hooks/useReportActionsPaginationScroll.ts index 634bc47c0128..86c97cada19f 100644 --- a/src/hooks/useReportActionsPaginationScroll.ts +++ b/src/hooks/useReportActionsPaginationScroll.ts @@ -1,7 +1,6 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import type {LegendListRef} from '@legendapp/list/react-native'; import type {RefObject} from 'react'; import {useEffect, useEffectEvent, useLayoutEffect, useRef} from 'react'; @@ -16,7 +15,6 @@ type PaginationGeometry = { type PaginationListRef = { getState: () => PaginationGeometry | undefined; - scrollToOffset: LegendListRef['scrollToOffset']; }; type ReportActionsPaginationDistances = { @@ -68,14 +66,11 @@ function useReportActionsPaginationScroll(options: UseReportActionsPaginationScr const lastRequestedNewerCursorRef = useRef(undefined); const wasNearOlderBoundaryRef = useRef(false); const wasNearNewerBoundaryRef = useRef(false); - const previousOlderCursorRef = useRef(undefined); - const previousNewerCursorRef = useRef(undefined); const wasOfflineRef = useRef(isOffline); const couldLoadOlderRef = useRef(canLoadOlder); const couldLoadNewerRef = useRef(canLoadNewer); const wasLoadingOlderRef = useRef(isLoadingOlderReportActions); const wasLoadingNewerRef = useRef(isLoadingNewerReportActions); - const pendingOlderRevealCursorRef = useRef(undefined); const paginationWindowGenerationRef = useRef(0); const scheduledFramesRef = useRef(new Set()); @@ -138,8 +133,6 @@ function useReportActionsPaginationScroll(options: UseReportActionsPaginationScr const threshold = latestArguments.viewportHeight * REPORT_ACTIONS_PAGINATION_THRESHOLD; const isNearOlderBoundary = distances.older <= threshold; const isNearNewerBoundary = distances.newer <= threshold; - const isWaitingForOlderReveal = pendingOlderRevealCursorRef.current === latestArguments.olderCursor; - if (!isNearOlderBoundary) { lastRequestedOlderCursorRef.current = undefined; } @@ -152,7 +145,6 @@ function useReportActionsPaginationScroll(options: UseReportActionsPaginationScr if ( isNearOlderBoundary && - !isWaitingForOlderReveal && latestArguments.canLoadOlder && latestArguments.hasOlderActions && !latestArguments.isOffline && @@ -186,59 +178,26 @@ function useReportActionsPaginationScroll(options: UseReportActionsPaginationScr const schedulePaginationBoundaryCheck = () => scheduleFrame(checkPaginationBoundaries); const cancelScheduledFramesEffect = useEffectEvent(cancelScheduledFrames); const schedulePaginationBoundaryCheckEffect = useEffectEvent(schedulePaginationBoundaryCheck); - const scheduleOlderRevealEffect = useEffectEvent((advancedCursor: string | undefined) => { - scheduleFrame(() => { - scheduleFrame(() => { - const latestArguments = latestArgumentsRef.current; - const listState = latestArguments.listRef.current?.getState(); - if ( - advancedCursor === latestArguments.olderCursor && - latestArguments.hasOlderActions && - listState && - listState.scroll + listState.scrollLength <= latestArguments.olderPaginationExtent - ) { - latestArguments.listRef.current?.scrollToOffset({offset: latestArguments.olderPaginationExtent, animated: false}); - } - if (pendingOlderRevealCursorRef.current === advancedCursor) { - pendingOlderRevealCursorRef.current = undefined; - } - checkPaginationBoundaries(); - }); - }); - }); useEffect(() => { paginationWindowGenerationRef.current += 1; cancelScheduledFramesEffect(); lastRequestedOlderCursorRef.current = undefined; lastRequestedNewerCursorRef.current = undefined; - pendingOlderRevealCursorRef.current = undefined; wasNearOlderBoundaryRef.current = false; wasNearNewerBoundaryRef.current = false; schedulePaginationBoundaryCheckEffect(); }, [reportID, linkedReportActionID]); useEffect(() => { - if (previousOlderCursorRef.current !== olderCursor) { - const previousOlderCursor = previousOlderCursorRef.current; - const didAdvanceAfterOwnRequest = !!previousOlderCursor && lastRequestedOlderCursorRef.current === previousOlderCursor; - previousOlderCursorRef.current = olderCursor; - lastRequestedOlderCursorRef.current = undefined; - if (didAdvanceAfterOwnRequest && hasOlderActions) { - const advancedCursor = olderCursor; - pendingOlderRevealCursorRef.current = advancedCursor; - scheduleOlderRevealEffect(advancedCursor); - } else { - pendingOlderRevealCursorRef.current = undefined; - schedulePaginationBoundaryCheckEffect(); - } - } - if (previousNewerCursorRef.current !== newerCursor) { - previousNewerCursorRef.current = newerCursor; - lastRequestedNewerCursorRef.current = undefined; - schedulePaginationBoundaryCheckEffect(); - } - }, [olderCursor, newerCursor, hasOlderActions]); + lastRequestedOlderCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [olderCursor]); + + useEffect(() => { + lastRequestedNewerCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [newerCursor]); useEffect(() => { const finishedLoadingOlder = wasLoadingOlderRef.current && !isLoadingOlderReportActions; @@ -259,7 +218,6 @@ function useReportActionsPaginationScroll(options: UseReportActionsPaginationScr lastRequestedOlderCursorRef.current = undefined; lastRequestedNewerCursorRef.current = undefined; - pendingOlderRevealCursorRef.current = undefined; wasNearOlderBoundaryRef.current = false; wasNearNewerBoundaryRef.current = false; schedulePaginationBoundaryCheckEffect(); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index bb2969b8dbd0..ed08f2cef688 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -70,7 +70,7 @@ import {useReportActionsListActions, useReportActionsListState} from './ReportAc import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; -import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from './ReportActionsPaginationSkeleton'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from './ReportActionsPaginationLoadingIndicator'; import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; @@ -258,10 +258,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; const onShowPreviousMessages = handleShowPreviousMessages; - const shouldShowOlderPaginationSkeleton = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; - const shouldShowNewerPaginationSkeleton = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; - const olderPaginationExtent = shouldShowOlderPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT + PAGINATION_TOP_OVERLAY_CLEARANCE : 0; - const newerPaginationExtent = shouldShowNewerPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT + newerFooterHeight : 0; + const canPaginateOlder = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; + const canPaginateNewer = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; + const shouldShowOlderPaginationLoadingIndicator = canPaginateOlder && !!isLoadingOlderReportActions && !hasLoadingOlderReportActionsError; + const shouldShowNewerPaginationLoadingIndicator = canPaginateNewer && !!isLoadingNewerReportActions && !hasLoadingNewerReportActionsError; + const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0; + const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ reportID, @@ -537,14 +539,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isDraftPendingCompletion={isDraftPendingCompletion} /> - {shouldShowNewerPaginationSkeleton && ( - - )} + {shouldShowNewerPaginationLoadingIndicator && } ); @@ -552,14 +547,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const olderListHeaderComponent = ( <> - {shouldShowOlderPaginationSkeleton && ( - - )} + {shouldShowOlderPaginationLoadingIndicator && } {shouldShowOfflineSkeleton && } ); @@ -591,22 +579,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ; } - const newerPaginationPageExtent = shouldShowNewerPaginationSkeleton ? viewportHeight + PAGINATION_SPINNER_HEIGHT : 0; - const shouldTargetNewestRealAction = initialScrollIndex === undefined && shouldShowNewerPaginationSkeleton && listData.length > 0; - const initialListIndex = (() => { - if (initialScrollIndex !== undefined) { - return {index: initialScrollIndex, ...initialScrollIndexParams}; - } - if (shouldTargetNewestRealAction) { - return { - index: listData.length - 1, - viewPosition: 1, - viewOffset: newerPaginationPageExtent, - }; - } - return undefined; - })(); - return ( <> + + + ); +} + +export default ReportActionsPaginationLoadingIndicator; +export {PAGINATION_LOADING_INDICATOR_HEIGHT, PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING}; +export type {PaginationDirection, ReportActionsPaginationLoadingIndicatorProps}; diff --git a/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx b/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx deleted file mode 100644 index bf964b07b279..000000000000 --- a/src/pages/inbox/report/ReportActionsPaginationSkeleton.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import ActivityIndicator from '@components/ActivityIndicator'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; - -import CONST from '@src/CONST'; - -import React from 'react'; -import {StyleSheet, View} from 'react-native'; - -type PaginationDirection = 'older' | 'newer'; - -type ReportActionsPaginationSkeletonProps = { - direction: PaginationDirection; - viewportHeight: number; - isLoading: boolean; - hasError: boolean; -}; - -const PAGINATION_SKELETON_ROW_HEIGHT = CONST.CHAT_SKELETON_VIEW.HEIGHT_FOR_ROW_COUNT[1]; -const PAGINATION_SPINNER_HEIGHT = 48; -const PAGINATION_TOP_OVERLAY_CLEARANCE = CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; - -const styles = StyleSheet.create({ - page: { - overflow: 'hidden', - }, - spinnerSlot: { - alignItems: 'center', - height: PAGINATION_SPINNER_HEIGHT, - justifyContent: 'center', - }, -}); - -function ReportActionsPaginationSkeleton({direction, viewportHeight, isLoading, hasError}: ReportActionsPaginationSkeletonProps) { - const testID = `report-actions-pagination-${direction}`; - const page = ( - - - - ); - const spinner = ( - - {isLoading && !hasError && ( - - )} - - ); - - return ( - - {direction === 'older' && } - {direction === 'older' && spinner} - {page} - {direction === 'newer' && spinner} - - ); -} - -export default ReportActionsPaginationSkeleton; -export {PAGINATION_SKELETON_ROW_HEIGHT, PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE}; -export type {PaginationDirection, ReportActionsPaginationSkeletonProps}; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 45313e33974a..07e64b52ed49 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -20,7 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; -import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from '@pages/inbox/report/ReportActionsPaginationSkeleton'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -228,7 +228,7 @@ type MockLegendListProps = { }) => void; }; -type PaginationSkeletonProps = React.ComponentProps; +type PaginationLoadingIndicatorProps = React.ComponentProps; 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'); @@ -237,18 +237,18 @@ const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/re const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); -function findPaginationSkeleton(node: React.ReactNode): React.ReactElement | undefined { +function findPaginationLoadingIndicator(node: React.ReactNode): React.ReactElement | undefined { if (!React.isValidElement<{children?: React.ReactNode}>(node)) { return undefined; } - if (node.type === ReportActionsPaginationSkeleton) { - return node as React.ReactElement; + if (node.type === ReportActionsPaginationLoadingIndicator) { + return node as React.ReactElement; } for (const child of React.Children.toArray(node.props.children)) { - const paginationSkeleton = findPaginationSkeleton(child); - if (paginationSkeleton) { - return paginationSkeleton; + const loadingIndicator = findPaginationLoadingIndicator(child); + if (loadingIndicator) { + return loadingIndicator; } } @@ -645,7 +645,7 @@ describe('ReportActionsList (body)', () => { ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); }); - describe('pagination placeholders', () => { + describe('pagination loading indicators', () => { it('measures the chat viewport before mounting LegendList', () => { mockUseNetwork.mockReturnValue({isOffline: false}); @@ -665,7 +665,7 @@ describe('ReportActionsList (body)', () => { expect(mockLegendList).toHaveBeenCalled(); }); - it('keeps one measured skeleton page at both available edges while requests are idle or loading', () => { + it('shows padded loading indicators only while requests are active', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, @@ -675,11 +675,9 @@ describe('ReportActionsList (body)', () => { }); const view = renderReportActionsList(); - let olderSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent); - let newerSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent); - expect(olderSkeleton?.props).toEqual(expect.objectContaining({direction: 'older', viewportHeight: 500, isLoading: false, hasError: false})); - expect(newerSkeleton?.props).toEqual(expect.objectContaining({direction: 'newer', viewportHeight: 500, isLoading: false, hasError: false})); - expect(getCapturedListProps()?.estimatedHeaderSize).toBe(500 + PAGINATION_SPINNER_HEIGHT + PAGINATION_TOP_OVERLAY_CLEARANCE); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); mockIsLoadingOlderReportActions = true; @@ -692,13 +690,25 @@ describe('ReportActionsList (body)', () => { />, ); - olderSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent); - newerSkeleton = findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent); - expect(olderSkeleton?.props.isLoading).toBe(true); - expect(newerSkeleton?.props.isLoading).toBe(true); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); + + mockHasLoadingOlderReportActionsError = true; + mockHasLoadingNewerReportActionsError = true; + view.rerender( + , + ); + + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); }); - it('positions an unanchored newer window at the last real action before the skeleton page', () => { + it('keeps an unanchored newer window aligned to the end when idle', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, @@ -708,11 +718,11 @@ describe('ReportActionsList (body)', () => { renderReportActionsList(); - expect(getCapturedListProps()?.initialScrollAtEnd).toBe(false); - expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: mockReportActions.length - 1, viewPosition: 1, viewOffset: 548}); + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.initialScrollIndex).toBeUndefined(); }); - it('preserves an explicit linked-action index when a newer skeleton page is present', () => { + it('preserves an explicit linked-action index when newer actions are available', () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockInitialScrollIndex = 0; mockInitialScrollIndexParams = {viewPosition: 0.5, viewOffset: 12}; @@ -727,8 +737,10 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.initialScrollIndex).toEqual({index: 0, viewPosition: 0.5, viewOffset: 12}); }); - it('removes only exhausted edge pages and omits pagination placeholders offline', () => { + it('removes an exhausted edge indicator and hides loading UI offline', () => { mockUseNetwork.mockReturnValue({isOffline: false}); + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, reportActions: mockReportActions, @@ -751,8 +763,8 @@ describe('ReportActionsList (body)', () => { />, ); - expect(findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); - expect(findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); mockUseNetwork.mockReturnValue({isOffline: true}); view.rerender( @@ -763,8 +775,8 @@ describe('ReportActionsList (body)', () => { />, ); - expect(findPaginationSkeleton(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); - expect(findPaginationSkeleton(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); }); }); diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx new file mode 100644 index 000000000000..b00c6cc71400 --- /dev/null +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -0,0 +1,56 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsPaginationLoadingIndicator, { + PAGINATION_LOADING_INDICATOR_HEIGHT, + PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING, +} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; + +import type {ComponentType} from 'react'; +import type {ViewProps} from 'react-native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +jest.mock('@components/ActivityIndicator', () => ({ + __esModule: true, + default: ({testID}: {testID?: string}) => { + const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); + return ; + }, +})); + +const OLDER_TEST_ID = 'report-actions-pagination-older'; +const NEWER_TEST_ID = 'report-actions-pagination-newer'; + +describe('ReportActionsPaginationLoadingIndicator', () => { + it('renders only a spinner with generous vertical padding', () => { + const view = render(); + + expect(StyleSheet.flatten(screen.getByTestId(OLDER_TEST_ID, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({ + alignItems: 'center', + height: PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingVertical: PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING, + }), + ); + expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); + + view.rerender(); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + }); + + it('keeps pagination loading UI out of interaction and accessibility', () => { + render(); + + expect(screen.getByTestId(NEWER_TEST_ID, {includeHiddenElements: true}).props).toEqual( + expect.objectContaining({ + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + pointerEvents: 'none', + }), + ); + }); +}); diff --git a/tests/unit/ReportActionsPaginationSkeletonTest.tsx b/tests/unit/ReportActionsPaginationSkeletonTest.tsx deleted file mode 100644 index 3d0993ee4b2c..000000000000 --- a/tests/unit/ReportActionsPaginationSkeletonTest.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import {render, screen} from '@testing-library/react-native'; - -import ReportActionsPaginationSkeleton, {PAGINATION_SPINNER_HEIGHT, PAGINATION_TOP_OVERLAY_CLEARANCE} from '@pages/inbox/report/ReportActionsPaginationSkeleton'; - -import type {ComponentType} from 'react'; -import type {ViewProps} from 'react-native'; - -import React from 'react'; -import {StyleSheet, View} from 'react-native'; - -const mockReportActionsSkeletonView = jest.fn((props: {possibleVisibleContentItems?: number; shouldAnimate?: boolean}) => ( - -)); - -jest.mock('@components/ReportActionsSkeletonView', () => ({ - __esModule: true, - default: (props: {possibleVisibleContentItems?: number; shouldAnimate?: boolean}) => mockReportActionsSkeletonView(props), -})); - -jest.mock('@components/ActivityIndicator', () => ({ - __esModule: true, - default: ({testID}: {testID?: string}) => { - const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); - return ; - }, -})); - -const VIEWPORT_HEIGHT = 361; -const OLDER_TEST_ID = 'report-actions-pagination-older'; -const NEWER_TEST_ID = 'report-actions-pagination-newer'; - -describe('ReportActionsPaginationSkeleton', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('covers one full viewport with enough static skeleton rows', () => { - render( - , - ); - - expect(StyleSheet.flatten(screen.getByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true}).props.style)).toEqual( - expect.objectContaining({height: VIEWPORT_HEIGHT, overflow: 'hidden'}), - ); - expect(mockReportActionsSkeletonView).toHaveBeenCalledWith({possibleVisibleContentItems: Math.ceil(VIEWPORT_HEIGHT / 60), shouldAnimate: false}); - }); - - it('puts the fixed spinner slot beyond the skeleton in each pagination direction', () => { - const view = render( - , - ); - - expect(view.toJSON()).toEqual( - expect.objectContaining({ - children: [ - expect.objectContaining({props: expect.objectContaining({style: {height: PAGINATION_TOP_OVERLAY_CLEARANCE}})}), - expect.objectContaining({props: expect.objectContaining({testID: `${OLDER_TEST_ID}-spinner-slot`})}), - expect.objectContaining({props: expect.objectContaining({testID: `${OLDER_TEST_ID}-skeleton`})}), - ], - }), - ); - expect(StyleSheet.flatten(screen.getByTestId(`${OLDER_TEST_ID}-spinner-slot`, {includeHiddenElements: true}).props.style)).toEqual( - expect.objectContaining({height: PAGINATION_SPINNER_HEIGHT}), - ); - expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); - - view.rerender( - , - ); - - expect(view.toJSON()).toEqual( - expect.objectContaining({ - children: [ - expect.objectContaining({props: expect.objectContaining({testID: `${NEWER_TEST_ID}-skeleton`})}), - expect.objectContaining({props: expect.objectContaining({testID: `${NEWER_TEST_ID}-spinner-slot`})}), - ], - }), - ); - expect(StyleSheet.flatten(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true}).props.style)).toEqual( - expect.objectContaining({height: PAGINATION_SPINNER_HEIGHT}), - ); - expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); - }); - - it('keeps the spinner slot stable while hiding the spinner when idle or failed', () => { - const view = render( - , - ); - - expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true})).toBeOnTheScreen(); - expect(screen.queryByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeNull(); - - view.rerender( - , - ); - - expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner-slot`, {includeHiddenElements: true})).toBeOnTheScreen(); - expect(screen.queryByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeNull(); - }); -}); diff --git a/tests/unit/useReportActionsPaginationScrollTest.tsx b/tests/unit/useReportActionsPaginationScrollTest.tsx index 4abd29d51c29..1e5dd91b4c30 100644 --- a/tests/unit/useReportActionsPaginationScrollTest.tsx +++ b/tests/unit/useReportActionsPaginationScrollTest.tsx @@ -3,14 +3,13 @@ import {act, renderHook} from '@testing-library/react-native'; import useReportActionsPaginationScroll, {REPORT_ACTIONS_PAGINATION_THRESHOLD} from '@hooks/useReportActionsPaginationScroll'; const VIEWPORT_HEIGHT = 500; -const NEWER_PAGINATION_EXTENT = VIEWPORT_HEIGHT + 48; -const OLDER_PAGINATION_EXTENT = NEWER_PAGINATION_EXTENT + 40; +const NEWER_PAGINATION_EXTENT = 168; +const OLDER_PAGINATION_EXTENT = 128; const CONTENT_HEIGHT = 3000; const BOUNDARY_DISTANCE = VIEWPORT_HEIGHT * 0.25; const mockLoadOlderActions = jest.fn(); const mockLoadNewerActions = jest.fn(); -const mockScrollToOffset = jest.fn(); const mockAnimationFrames: FrameRequestCallback[] = []; const mockTransitionCallbacks: Array<() => void> = []; let mockIsSearchTopmostFullScreenRoute = false; @@ -36,7 +35,6 @@ let listMetrics = { const listRef = { current: { getState: () => listMetrics, - scrollToOffset: mockScrollToOffset, }, }; @@ -176,36 +174,6 @@ describe('useReportActionsPaginationScroll', () => { expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); }); - it('reveals the first real older action when a response arrives while the viewport is entirely in the skeleton', () => { - const initialParams = buildParams({hasNewerActions: false}); - const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); - - setListMetrics(0); - act(() => { - result.current.onScroll(); - }); - expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); - - rerender({...initialParams, olderCursor: 'older-2'}); - flushAnimationFrames(); - - expect(mockScrollToOffset).toHaveBeenCalledWith({offset: OLDER_PAGINATION_EXTENT, animated: false}); - }); - - it('leaves a partial-skeleton viewport anchored when it already contains a real older action', () => { - const initialParams = buildParams({hasNewerActions: false}); - const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); - - setListMetrics(100); - act(() => { - result.current.onScroll(); - }); - rerender({...initialParams, olderCursor: 'older-2'}); - flushAnimationFrames(); - - expect(mockScrollToOffset).not.toHaveBeenCalled(); - }); - it('uses current availability when a scheduled content-size check runs', () => { const initialParams = buildParams({hasNewerActions: false}); const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); From a99e89428c0e94fc550f37a965519f51fe3210fa Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 20:35:45 +0200 Subject: [PATCH 34/39] fix: stabilize chat pagination loader layout --- src/pages/inbox/report/ReportActionsList.tsx | 1 - .../report/ReportActionsPaginationLoadingIndicator.tsx | 10 ++++++---- tests/ui/ReportActionsListTest.tsx | 7 ++++--- .../ReportActionsPaginationLoadingIndicatorTest.tsx | 7 +++++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ed08f2cef688..ede56fd1652e 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -615,7 +615,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ListHeaderComponent={olderListHeaderComponent} ListFooterComponent={newerListFooterComponent} ListFooterComponentStyle={shouldBeAlignedToTop ? styles.flex1 : undefined} - estimatedHeaderSize={olderPaginationExtent} keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx index 60546f86308e..aa7daf1509b9 100644 --- a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -11,15 +11,17 @@ type ReportActionsPaginationLoadingIndicatorProps = { direction: PaginationDirection; }; -const PAGINATION_LOADING_INDICATOR_HEIGHT = 128; -const PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING = 48; +const PAGINATION_LOADING_INDICATOR_HEIGHT = 96; +const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 48; +const PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING = 24; const styles = StyleSheet.create({ container: { alignItems: 'center', height: PAGINATION_LOADING_INDICATOR_HEIGHT, justifyContent: 'center', - paddingVertical: PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING, + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, }, }); @@ -43,5 +45,5 @@ function ReportActionsPaginationLoadingIndicator({direction}: ReportActionsPagin } export default ReportActionsPaginationLoadingIndicator; -export {PAGINATION_LOADING_INDICATOR_HEIGHT, PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING}; +export {PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, PAGINATION_LOADING_INDICATOR_HEIGHT, PAGINATION_LOADING_INDICATOR_TOP_PADDING}; export type {PaginationDirection, ReportActionsPaginationLoadingIndicatorProps}; diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 07e64b52ed49..6e08b7e2d9d7 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -20,7 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; -import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; +import ReportActionsPaginationLoadingIndicator from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -677,7 +677,7 @@ describe('ReportActionsList (body)', () => { expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); - expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); + expect(getCapturedListProps()?.estimatedHeaderSize).toBeUndefined(); expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); mockIsLoadingOlderReportActions = true; @@ -692,7 +692,8 @@ describe('ReportActionsList (body)', () => { expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); - expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); + expect(getCapturedListProps()?.estimatedHeaderSize).toBeUndefined(); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); mockHasLoadingOlderReportActionsError = true; mockHasLoadingNewerReportActionsError = true; diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx index b00c6cc71400..33785bb9331d 100644 --- a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -1,8 +1,9 @@ import {render, screen} from '@testing-library/react-native'; import ReportActionsPaginationLoadingIndicator, { + PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, PAGINATION_LOADING_INDICATOR_HEIGHT, - PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING, + PAGINATION_LOADING_INDICATOR_TOP_PADDING, } from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import type {ComponentType} from 'react'; @@ -31,9 +32,11 @@ describe('ReportActionsPaginationLoadingIndicator', () => { alignItems: 'center', height: PAGINATION_LOADING_INDICATOR_HEIGHT, justifyContent: 'center', - paddingVertical: PAGINATION_LOADING_INDICATOR_VERTICAL_PADDING, + paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, + paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, }), ); + expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(PAGINATION_LOADING_INDICATOR_TOP_PADDING / 2); expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); From f348e263cd01576a35ac642272ff741ee01ad05b Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 20:40:39 +0200 Subject: [PATCH 35/39] fix: restore chat pagination header estimate --- src/pages/inbox/report/ReportActionsList.tsx | 1 + .../report/ReportActionsPaginationLoadingIndicator.tsx | 4 ++-- tests/ui/ReportActionsListTest.tsx | 6 +++--- tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx | 3 ++- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index ede56fd1652e..ed08f2cef688 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -615,6 +615,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct ListHeaderComponent={olderListHeaderComponent} ListFooterComponent={newerListFooterComponent} ListFooterComponentStyle={shouldBeAlignedToTop ? styles.flex1 : undefined} + estimatedHeaderSize={olderPaginationExtent} keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx index aa7daf1509b9..8e368716014e 100644 --- a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -11,8 +11,8 @@ type ReportActionsPaginationLoadingIndicatorProps = { direction: PaginationDirection; }; -const PAGINATION_LOADING_INDICATOR_HEIGHT = 96; -const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 48; +const PAGINATION_LOADING_INDICATOR_HEIGHT = 72; +const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 24; const PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING = 24; const styles = StyleSheet.create({ diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 6e08b7e2d9d7..8e1d9b3fbd8b 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -20,7 +20,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {useConciergeDraft, useConciergeDraftActions} from '@pages/inbox/ConciergeDraftContext'; import {useConciergeSessionActions, useConciergeSessionState} from '@pages/inbox/ConciergeSessionContext'; import ReportActionsList from '@pages/inbox/report/ReportActionsList'; -import ReportActionsPaginationLoadingIndicator from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; +import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -677,7 +677,7 @@ describe('ReportActionsList (body)', () => { expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); - expect(getCapturedListProps()?.estimatedHeaderSize).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); expect(getCapturedVisibleActions()).toEqual(mockReportActions.toReversed()); mockIsLoadingOlderReportActions = true; @@ -692,7 +692,7 @@ describe('ReportActionsList (body)', () => { expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); - expect(getCapturedListProps()?.estimatedHeaderSize).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); mockHasLoadingOlderReportActionsError = true; diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx index 33785bb9331d..d603ecdbf478 100644 --- a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -36,7 +36,8 @@ describe('ReportActionsPaginationLoadingIndicator', () => { paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, }), ); - expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(PAGINATION_LOADING_INDICATOR_TOP_PADDING / 2); + expect(PAGINATION_LOADING_INDICATOR_TOP_PADDING).toBe(24); + expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(24); expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); From 1e37d2e917feb88ec49c002630da80cb95b4e6fc Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 12:03:04 +0200 Subject: [PATCH 36/39] fix: improve pagination loader spacing and style usage --- ...eportActionsPaginationLoadingIndicator.tsx | 21 ++++++------------- src/styles/utils/spacing.ts | 4 ++++ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx index 8e368716014e..226a0e2c03b8 100644 --- a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -1,9 +1,11 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import useThemeStyles from '@hooks/useThemeStyles'; + import CONST from '@src/CONST'; import React from 'react'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; type PaginationDirection = 'older' | 'newer'; @@ -12,26 +14,15 @@ type ReportActionsPaginationLoadingIndicatorProps = { }; const PAGINATION_LOADING_INDICATOR_HEIGHT = 72; -const PAGINATION_LOADING_INDICATOR_TOP_PADDING = 24; -const PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING = 24; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - height: PAGINATION_LOADING_INDICATOR_HEIGHT, - justifyContent: 'center', - paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, - paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, - }, -}); function ReportActionsPaginationLoadingIndicator({direction}: ReportActionsPaginationLoadingIndicatorProps) { + const styles = useThemeStyles(); const testID = `report-actions-pagination-${direction}`; return ( Date: Fri, 11 Sep 2026 16:48:56 +0200 Subject: [PATCH 37/39] fix: align pagination loading indicator sizing --- src/CONST/index.ts | 1 + src/pages/inbox/report/ReportActionsList.tsx | 8 ++++--- ...eportActionsPaginationLoadingIndicator.tsx | 5 +---- tests/ui/ReportActionsListTest.tsx | 4 ++-- ...tActionsPaginationLoadingIndicatorTest.tsx | 21 +++++++++---------- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 790a694ca44b..1c1bb8ecba22 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6996,6 +6996,7 @@ const CONST = { MOBILE_PAGINATION_SIZE: 15, WEB_PAGINATION_SIZE: 30, + PAGINATION_LOADING_INDICATOR_HEIGHT: 72, /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 76bfd44e3f10..1d11dd3d954a 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -72,7 +72,7 @@ import {useReportActionsListActions, useReportActionsListState} from './ReportAc import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import ReportActionsListPaddingView from './ReportActionsListPaddingView'; -import ReportActionsPaginationLoadingIndicator, {PAGINATION_LOADING_INDICATOR_HEIGHT} from './ReportActionsPaginationLoadingIndicator'; +import ReportActionsPaginationLoadingIndicator from './ReportActionsPaginationLoadingIndicator'; import ReportActionsSkeletonGuard from './ReportActionsSkeletonGuard'; import ShowPreviousMessagesButton from './ShowPreviousMessagesButton'; import useFollowActionBadgeTarget from './useFollowActionBadgeTarget'; @@ -268,8 +268,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const canPaginateNewer = viewportHeight > 0 && !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; const shouldShowOlderPaginationLoadingIndicator = canPaginateOlder && !!isLoadingOlderReportActions && !hasLoadingOlderReportActionsError; const shouldShowNewerPaginationLoadingIndicator = canPaginateNewer && !!isLoadingNewerReportActions && !hasLoadingNewerReportActionsError; - const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0; - const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; + const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? CONST.PAGINATION_LOADING_INDICATOR_HEIGHT : 0; + const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? CONST.PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ reportID, @@ -486,6 +486,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const reportActionIndex = renderedVisibleReportActions.length - index - 1; return ( + // This context has to be created for each item, therefore it cannot be memoized. + // eslint-disable-next-line react/jsx-no-constructed-context-values { expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); - expect(getCapturedListProps()?.estimatedHeaderSize).toBe(PAGINATION_LOADING_INDICATOR_HEIGHT); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(CONST.PAGINATION_LOADING_INDICATOR_HEIGHT); expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); mockHasLoadingOlderReportActionsError = true; diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx index d603ecdbf478..80e13ac19cef 100644 --- a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -1,10 +1,10 @@ -import {render, screen} from '@testing-library/react-native'; +import {render, renderHook, screen} from '@testing-library/react-native'; -import ReportActionsPaginationLoadingIndicator, { - PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, - PAGINATION_LOADING_INDICATOR_HEIGHT, - PAGINATION_LOADING_INDICATOR_TOP_PADDING, -} from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import ReportActionsPaginationLoadingIndicator from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; + +import CONST from '@src/CONST'; import type {ComponentType} from 'react'; import type {ViewProps} from 'react-native'; @@ -25,19 +25,18 @@ const NEWER_TEST_ID = 'report-actions-pagination-newer'; describe('ReportActionsPaginationLoadingIndicator', () => { it('renders only a spinner with generous vertical padding', () => { + const {result: styles} = renderHook(useThemeStyles); const view = render(); expect(StyleSheet.flatten(screen.getByTestId(OLDER_TEST_ID, {includeHiddenElements: true}).props.style)).toEqual( expect.objectContaining({ alignItems: 'center', - height: PAGINATION_LOADING_INDICATOR_HEIGHT, + height: CONST.PAGINATION_LOADING_INDICATOR_HEIGHT, justifyContent: 'center', - paddingBottom: PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING, - paddingTop: PAGINATION_LOADING_INDICATOR_TOP_PADDING, + paddingBottom: styles.current.pb6.paddingBottom, + paddingTop: styles.current.pt16.paddingTop, }), ); - expect(PAGINATION_LOADING_INDICATOR_TOP_PADDING).toBe(24); - expect(PAGINATION_LOADING_INDICATOR_BOTTOM_PADDING).toBe(24); expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); From 391d1cf625a3eef8a14b628d72887b816dbe0322 Mon Sep 17 00:00:00 2001 From: chrispader Date: Fri, 11 Sep 2026 17:18:53 +0200 Subject: [PATCH 38/39] fix: stabilize report actions loading behavior --- .../MoneyRequestReportView.tsx | 8 +-- src/components/ReportActionsSkeletonCover.tsx | 54 ++++++++++++++++ src/hooks/useReportActionsListModel.ts | 2 + src/pages/inbox/report/ReportActionsList.tsx | 22 +++---- .../report/ReportActionsLoadingSkeleton.tsx | 6 +- tests/ui/MoneyRequestReportViewTest.tsx | 48 ++++++++++++++- tests/ui/ReportActionsListTest.tsx | 35 ++++++++++- tests/ui/ReportActionsTest.tsx | 3 + tests/unit/ReportActionsSkeletonCoverTest.tsx | 61 +++++++++++++++++++ 9 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 src/components/ReportActionsSkeletonCover.tsx create mode 100644 tests/unit/ReportActionsSkeletonCoverTest.tsx diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index d411a44189a6..c40550bf542c 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -3,7 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; -import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -105,7 +105,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo {}} /> - + ); } @@ -217,7 +217,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, } if (shouldShowEmptyActionsSkeleton) { - return ; + return ; } if (!report) { @@ -228,7 +228,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, return ( - + {shouldDisplayReportFooter ? : null} ); diff --git a/src/components/ReportActionsSkeletonCover.tsx b/src/components/ReportActionsSkeletonCover.tsx new file mode 100644 index 000000000000..0850fcd45b66 --- /dev/null +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -0,0 +1,54 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +import ReportActionsSkeletonView from './ReportActionsSkeletonView'; + +type ReportActionsSkeletonCoverProps = { + /** Additional styles for the cover */ + style?: StyleProp; +}; + +type ReportActionsSkeletonContainerProps = ReportActionsSkeletonCoverProps & { + /** The skeleton content to place at the bottom of the report viewport */ + children: ReactNode; +}; + +/** Fills the report-actions viewport with a consistently positioned static loading skeleton. */ +function ReportActionsSkeletonCover({style}: ReportActionsSkeletonCoverProps) { + return ( + + + + ); +} + +/** Fills the report-actions viewport with a consistently positioned animated loading skeleton. */ +function ReportActionsAnimatedSkeletonCover({style}: ReportActionsSkeletonCoverProps) { + return ( + + + + ); +} + +function ReportActionsSkeletonContainer({children, style}: ReportActionsSkeletonContainerProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +export {ReportActionsAnimatedSkeletonCover}; +export default ReportActionsSkeletonCover; diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index a7b0fc35fbab..229dcbf763ff 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -56,6 +56,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea }); const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; const isLoadingNewerReportActions = reportLoadingState?.isLoadingNewerReportActions; @@ -149,6 +150,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, hasOlderActions, hasNewerActions, isLoadingOlderReportActions, diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1d11dd3d954a..48874fd9f5d5 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,6 +1,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import type {ActionListRef} from '@components/FlashList/types'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; @@ -157,6 +158,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, hasOlderActions, hasNewerActions, isLoadingOlderReportActions, @@ -191,10 +193,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const didLayout = useRef(false); const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); - useEffect(() => { - didLayout.current = false; - }, [reportID]); - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. @@ -421,7 +419,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct setTreatAsNoPaginationAnchor, }); - const shouldShowInitialViewportSkeleton = !isOffline && (!hasOnceLoadedReportActions || loadedInitialViewportListID !== listID); + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); const handleListLoad = () => { onLoad(); @@ -487,7 +485,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ( // This context has to be created for each item, therefore it cannot be memoized. - // eslint-disable-next-line react/jsx-no-constructed-context-values ; + return ; } return ( @@ -628,6 +625,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyExtractor={keyExtractor} drawDistance={REPORT_ACTIONS_DRAW_DISTANCE} recycleItems + experimental_hideItemsUntilMeasured renderScrollComponent={renderActionSheetAwareScrollView} contentContainerStyle={styles.chatContentScrollView} ListHeaderComponent={olderListHeaderComponent} @@ -656,15 +654,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct }} /> ) : ( - + )} {viewportHeight > 0 && shouldShowInitialViewportSkeleton && ( - - - + )} @@ -680,6 +673,7 @@ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsLis return ( ; + const SkeletonCover = shouldAnimate ? ReportActionsAnimatedSkeletonCover : ReportActionsSkeletonCover; + + return ; } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index f0dc787734b5..593fd2a8c091 100644 --- a/tests/ui/MoneyRequestReportViewTest.tsx +++ b/tests/ui/MoneyRequestReportViewTest.tsx @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ -import {render} from '@testing-library/react-native'; +import {render, screen} from '@testing-library/react-native'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -34,6 +36,10 @@ jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/usePaginatedReportActions', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@hooks/useInFlightRequests', () => ({ + useIsAppLoadPending: jest.fn(), + useIsReportLoadPending: jest.fn(), +})); // useThemeStyles throws without a ; return a proxy that yields an empty style object // for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. @@ -54,6 +60,8 @@ jest.mock('@components/MoneyReportHeader', () => jest.fn(() => null)); jest.mock('@components/MoneyRequestHeader', () => jest.fn(() => null)); jest.mock('@components/CollapsibleHeaderOnKeyboard', () => jest.fn(() => null)); jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => jest.fn(() => null)); +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); +jest.mock('@components/ReportHeaderSkeletonView', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportFooter', () => jest.fn(() => null)); jest.mock('@components/OfflineWithFeedback', () => { const reactModule = jest.requireActual('react'); @@ -61,12 +69,15 @@ jest.mock('@components/OfflineWithFeedback', () => { }); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseIsAppLoadPending = jest.mocked(useIsAppLoadPending); +const mockUseIsReportLoadPending = jest.mocked(useIsReportLoadPending); const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction; const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction; const mockMoneyRequestReportActionsList = MoneyRequestReportActionsList as jest.MockedFunction; const mockReportActionsListBody = ReportActionsList as jest.MockedFunction; +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction; const defaultPaginatedReportActionsResult: ReturnType = { @@ -134,6 +145,8 @@ describe('MoneyRequestReportView', () => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsAppLoadPending.mockReturnValue(false); + mockUseIsReportLoadPending.mockReturnValue(false); mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult); mockUseReportTransactionsCollection.mockReturnValue({}); mockUseResponsiveLayout.mockReturnValue({ @@ -176,6 +189,39 @@ describe('MoneyRequestReportView', () => { expect(MoneyRequestReportUtils.shouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], mockReportLoadingState, false, false); }); + it('uses the animated bottom-padded cover while the report is waiting for transactions', () => { + jest.spyOn(MoneyRequestReportUtils, 'shouldWaitForTransactions').mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses the static bottom-padded cover while report actions are empty', () => { + jest.spyOn(ReportActionsUtils, 'getFilteredReportActionsForReportView').mockReturnValue([]); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses the animated bottom-padded cover while the app is loading', () => { + mockUseIsAppLoadPending.mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + it('mounts the chat list body and the typing listener (not the table view) for a transaction-thread report', () => { const onLayout = jest.fn(); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 5300b0ab1e85..f9c257f17a2b 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -90,6 +90,7 @@ function getMockReportLoadingState( selector: unknown, hasOnceLoadedReportActions = true, paginationState: { + isLoadingInitialReportActions?: boolean; isLoadingOlderReportActions?: boolean; hasLoadingOlderReportActionsError?: boolean; isLoadingNewerReportActions?: boolean; @@ -211,6 +212,8 @@ type MockLegendListProps = { maintainScrollAtEnd?: {animated: boolean} | false; maintainScrollAtEndThreshold?: number; maintainVisibleContentPosition?: boolean; + // eslint-disable-next-line @typescript-eslint/naming-convention + experimental_hideItemsUntilMeasured?: boolean; ListHeaderComponent?: React.ReactNode; ListFooterComponent?: React.ReactNode; ListFooterComponentStyle?: unknown; @@ -278,6 +281,7 @@ const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); let mockHasOnceLoadedReportActions = true; +let mockIsLoadingInitialReportActions = false; let mockIsLoadingOlderReportActions = false; let mockHasLoadingOlderReportActionsError = false; let mockIsLoadingNewerReportActions = false; @@ -364,6 +368,7 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; mockInitialScrollIndex = undefined; mockInitialScrollIndexParams = undefined; mockIsLoadingOlderReportActions = false; @@ -435,6 +440,7 @@ describe('ReportActionsList (body)', () => { if (key.includes('reportLoadingState')) { return [ getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, { + isLoadingInitialReportActions: mockIsLoadingInitialReportActions, isLoadingOlderReportActions: mockIsLoadingOlderReportActions, hasLoadingOlderReportActionsError: mockHasLoadingOlderReportActionsError, isLoadingNewerReportActions: mockIsLoadingNewerReportActions, @@ -468,6 +474,7 @@ describe('ReportActionsList (body)', () => { expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + expect(getCapturedListProps()?.experimental_hideItemsUntilMeasured).toBe(true); }); it('initially aligns the seed page to the end', () => { @@ -522,6 +529,7 @@ describe('ReportActionsList (body)', () => { it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; mockShouldCallLegendListOnLoad = false; const view = renderReportActionsList(); @@ -550,6 +558,22 @@ describe('ReportActionsList (body)', () => { expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); }); + it('releases the initial viewport cover after a terminal OpenReport failure', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + it('keeps the initial actions visible until the hydrated page is complete', async () => { mockUseNetwork.mockReturnValue({isOffline: false}); mockHasOnceLoadedReportActions = false; @@ -1232,7 +1256,12 @@ describe('ReportActionsList (body)', () => { }, ]; - const setupMainDMConciergeMocks = (sessionStartTime: string | null = SESSION_START, showFullHistory = false, hasOnceLoadedReportActions = true) => { + const setupMainDMConciergeMocks = ( + sessionStartTime: string | null = SESSION_START, + showFullHistory = false, + hasOnceLoadedReportActions = true, + isLoadingInitialReportActions = false, + ) => { jest.spyOn(ReportActionsUtils, 'shouldReportActionBeVisible').mockReturnValue(true); mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); @@ -1251,7 +1280,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions, {isLoadingInitialReportActions}), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -1419,7 +1448,7 @@ describe('ReportActionsList (body)', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport // finishes so the final hydrated viewport is the first report content the user sees. - setupMainDMConciergeMocks(SESSION_START, false, false); + setupMainDMConciergeMocks(SESSION_START, false, false, true); mockUsePaginatedReportActions.mockReturnValue({ ...defaultPaginatedReportActionsResult, diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index 18f7f63e2496..074d6980792b 100644 --- a/tests/ui/ReportActionsTest.tsx +++ b/tests/ui/ReportActionsTest.tsx @@ -149,6 +149,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); @@ -159,6 +160,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); @@ -197,6 +199,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(REPORT_ID, mockReport, {warm: false}); diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx new file mode 100644 index 000000000000..0101438d1efa --- /dev/null +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -0,0 +1,61 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import React from 'react'; + +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); + +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); + +describe('ReportActionsSkeletonCover', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fills, clips, and bottom-aligns the static report-actions skeleton', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + flex: 1, + overflow: 'hidden', + justifyContent: 'flex-end', + paddingBottom: 16, + }); + expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + }); + + it('fills the report-actions viewport with an animated skeleton', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView).toHaveBeenCalledTimes(1); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: true})); + }); + + it('accepts additional presentation styles', () => { + render( + , + ); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 10, + }); + }); +}); From 9ac3a73eb0532ce2c6e028095b516dbfe1ba414d Mon Sep 17 00:00:00 2001 From: chrispader Date: Mon, 14 Sep 2026 11:29:20 +0200 Subject: [PATCH 39/39] fix: allow report action menus to overflow list rows --- .../LegendList/allowLegendListItemOverflow.ts | 22 +++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 15 +++++++-- tests/ui/ReportActionsListTest.tsx | 33 +++++++++++++++++++ tests/unit/LegendListItemOverflowTest.ts | 31 +++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/components/LegendList/allowLegendListItemOverflow.ts create mode 100644 tests/unit/LegendListItemOverflowTest.ts diff --git a/src/components/LegendList/allowLegendListItemOverflow.ts b/src/components/LegendList/allowLegendListItemOverflow.ts new file mode 100644 index 000000000000..5907b06815e1 --- /dev/null +++ b/src/components/LegendList/allowLegendListItemOverflow.ts @@ -0,0 +1,22 @@ +import type {LegendListRef} from '@legendapp/list/react-native'; + +/** + * LegendList uses paint containment for web item containers, which clips descendants that render + * outside their row. Remove only paint containment so inline menus can overflow while layout and + * style containment keep the rest of the row isolated. + */ +function allowLegendListItemOverflow(list: LegendListRef | null, index: number): boolean { + const itemContainer: unknown = list?.getState().elementAtIndex(index); + if (!isRecord(itemContainer) || !isRecord(itemContainer.style) || typeof itemContainer.style.contain !== 'string') { + return false; + } + + itemContainer.style.contain = 'layout style'; + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export default allowLegendListItemOverflow; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 48874fd9f5d5..49a427c184ff 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,6 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import type {ActionListRef} from '@components/FlashList/types'; +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -57,7 +58,7 @@ 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 {LegendListRef, LegendListRenderItemProps, OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -421,6 +422,16 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + const handleViewableItemsChanged = (info: OnViewableItemsChangedInfo) => { + onViewableItemsChanged(info); + for (const item of info.changed) { + if (!item.isViewable) { + continue; + } + allowLegendListItemOverflow(legendListRef.current, item.index); + } + }; + const handleListLoad = () => { onLoad(); setLoadedInitialViewportListID(listID); @@ -635,7 +646,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct keyboardShouldPersistTaps="handled" onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} - onViewableItemsChanged={onViewableItemsChanged} + onViewableItemsChanged={handleViewableItemsChanged} extraData={extraData} key={listID} getItemType={getItemType} diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index f9c257f17a2b..c12895494e98 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,5 +1,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ @@ -27,6 +29,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {reportActionsListLoadingStateSelector} from '@src/selectors/ReportMetaData'; import type * as OnyxTypes from '@src/types/onyx'; +import type {OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; import type * as ReactNavigation from '@react-navigation/native'; import React from 'react'; @@ -62,6 +65,7 @@ jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); jest.mock('@hooks/useSidePanelState', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@components/LegendList/allowLegendListItemOverflow', () => jest.fn()); jest.mock('@pages/inbox/ConciergeSessionContext', () => ({ useConciergeSessionState: jest.fn(), useConciergeSessionActions: jest.fn(), @@ -85,6 +89,7 @@ const mockUseConciergeDraft = useConciergeDraft as jest.MockedFunction; const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; +const mockAllowLegendListItemOverflow = allowLegendListItemOverflow as jest.MockedFunction; function getMockReportLoadingState( selector: unknown, @@ -219,6 +224,7 @@ type MockLegendListProps = { ListFooterComponentStyle?: unknown; onLoad?: () => void; onContentSizeChange?: (width: number, height: number) => void; + onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; onStartReached?: () => void; @@ -621,6 +627,33 @@ describe('ReportActionsList (body)', () => { expect(listProps?.recycleItems).toBe(true); }); + it('allows visible report action menus to overflow their LegendList item containers', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const hiddenAction = mockReportActions.at(0); + const visibleAction = mockReportActions.at(1); + if (!hiddenAction || !visibleAction) { + throw new Error('Expected report action fixtures'); + } + + act(() => { + getCapturedListProps()?.onViewableItemsChanged?.({ + changed: [ + {containerId: 1, index: 1, isViewable: true, item: visibleAction, key: '1'}, + {containerId: 0, index: 0, isViewable: false, item: hiddenAction, key: '0'}, + ], + end: 1, + endBuffered: 1, + start: 0, + startBuffered: 0, + viewableItems: [], + }); + }); + + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledTimes(1); + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledWith(null, 1); + }); + it('groups comments by layout characteristics for measurement estimates', () => { mockUseNetwork.mockReturnValue({isOffline: false}); renderReportActionsList(); diff --git a/tests/unit/LegendListItemOverflowTest.ts b/tests/unit/LegendListItemOverflowTest.ts new file mode 100644 index 000000000000..4cbf62a78432 --- /dev/null +++ b/tests/unit/LegendListItemOverflowTest.ts @@ -0,0 +1,31 @@ +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +import createMock from '../utils/createMock'; + +describe('allowLegendListItemOverflow', () => { + it('removes paint containment from a web item container', () => { + const style = {contain: 'paint layout style'}; + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({style}), + }), + }); + + expect(allowLegendListItemOverflow(list, 3)).toBe(true); + expect(style.contain).toBe('layout style'); + }); + + it('does nothing for a native item container', () => { + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({setNativeProps: jest.fn()}), + }), + }); + + expect(allowLegendListItemOverflow(list, 3)).toBe(false); + }); +});